Lambda-Performance und Cold Starts verstehen
Was ein Cold Start bei AWS Lambda wirklich ist, welche Hebel du steuern kannst – von Bundle-Size über Init-Code und Memory bis Provisioned Concurrency – und wie du das Ganze sauber misst.
"Die Funktion ist manchmal einfach lahm." Diesen Satz höre ich in Architektur-Reviews zu Serverless-Systemen erstaunlich oft. Ein Endpunkt antwortet in 40 Millisekunden – und beim nächsten Aufruf plötzlich in 900. Nichts am Code hat sich geändert, das Monitoring zeigt keine Fehler, und trotzdem ist da dieser wiederkehrende Ausreißer, der alle stört und den keiner richtig greifen kann. In fast allen Fällen landen wir am Ende beim selben Thema: dem Cold Start.
Cold Starts haben einen schlechten Ruf, und das zu Unrecht. Sie sind kein Bug und kein Zeichen für eine schlecht gebaute Funktion. Sie sind eine direkte Folge davon, wie AWS Lambda arbeitet – und wenn man dieses Modell einmal verstanden hat, wird aus dem diffusen "manchmal lahm" eine Handvoll konkreter, steuerbarer Stellschrauben. Genau darum geht es in diesem Artikel: Ich möchte das Modell dahinter erklären, zeigen, welche Hebel du tatsächlich in der Hand hast, und mit ein paar hartnäckigen Fehlannahmen aufräumen, die mir in der Praxis immer wieder begegnen.
Was bei einem Cold Start wirklich passiert
Lambda führt deinen Code nicht in einem dauerhaft laufenden Prozess aus, sondern in einer sogenannten Execution Environment. Das ist eine isolierte, kurzlebige Laufzeitumgebung, die Lambda bei Bedarf erzeugt, eine Weile am Leben hält und irgendwann wieder wegwirft. Der Lebenszyklus dieser Umgebung besteht aus drei Phasen: Init, Invoke und Shutdown.
Ein Cold Start ist nichts anderes als die Init-Phase einer frisch erzeugten Environment. Dabei passiert einiges, bevor deine eigentliche Logik überhaupt anläuft:
- Lambda lädt deinen Code herunter – aus einem internen Speicher beziehungsweise bei Container-Images aus der Registry.
- Die Runtime wird initialisiert, also zum Beispiel der Node.js-Prozess hochgefahren.
- Dein Function-Init-Code läuft: alles, was du außerhalb des Handlers auf Modulebene geschrieben hast – Imports, das Anlegen von SDK-Clients, das Einlesen von Konfiguration.
Erst danach folgt die Invoke-Phase, in der dein Handler mit dem konkreten Event aufgerufen wird. Ein wichtiges Detail für die Praxis: Die Init-Phase hat einen harten Timeout von 10 Sekunden. Wer teure Arbeit auf Modulebene erledigt, kann diese Grenze tatsächlich reißen – dazu später mehr.
Nach dem Invoke wirft Lambda die Umgebung nicht sofort weg. Sie wird eingefroren ("frozen") und bei einem weiteren Aufruf innerhalb kurzer Zeit wieder aufgetaut ("thawed"). Dann sprechen wir von einem Warm Invoke: Die komplette Init-Phase entfällt, weil Runtime und Init-Code bereits gelaufen sind. Es läuft nur noch dein Handler. Das ist der Grund, warum derselbe Endpunkt mal 40 und mal 900 Millisekunden braucht – der Unterschied ist schlicht, ob eine warme Environment bereitstand oder eine neue erzeugt werden musste.
flowchart TD
Req[Eingehender Invoke] --> Q{Warme Environment<br/>verfügbar?}
Q -->|Nein: Cold Start| C1[Code herunterladen]
C1 --> C2[Runtime initialisieren]
C2 --> C3[Init-Code außerhalb<br/>des Handlers ausführen]
C3 --> H[Handler ausführen]
Q -->|Ja: Warm Invoke| H
H --> F[Environment einfrieren<br/>für spätere Invokes]
F -.wiederverwenden.-> Q
Das Diagramm macht den zentralen Punkt sichtbar: Der teure Teil – Download, Runtime-Init, Function-Init – passiert nur auf dem Cold-Pfad. Die "Init Duration", die du gleich noch in den Logs findest, ist genau die Summe dieser drei Schritte. Bei jedem Warm Invoke springt Lambda direkt zum Handler.
Der erste Hebel: das Bundle klein halten
Der offensichtlichste Beitrag zum Cold Start ist die Zeit, die es braucht, deinen Code zu laden und die Module zu parsen. Je größer das Bundle und je verzweigter der Import-Graph, desto länger dauert das. Das klingt banal, aber ich sehe regelmäßig Deployment-Pakete von mehreren Megabyte, in denen ein komplettes SDK, drei Datumsbibliotheken und ungenutzte Teile eines Frameworks mitreisen.
Die pragmatische Antwort heißt Bundling mit Tree-Shaking. Ein Bundler wie esbuild wirft alles raus, was nicht tatsächlich erreicht wird, und schnürt den Rest zu einer kompakten Datei. Ein typischer Aufruf sieht so aus:
esbuild src/handler.ts \
--bundle \
--minify \
--platform=node \
--target=node18 \
--external:@aws-sdk/* \
--outfile=dist/handler.js
Zwei Dinge sind hier bewusst gewählt. Das Target ist node18 – die zu diesem Zeitpunkt aktuelle LTS-Runtime von Lambda. Und das AWS SDK v3 wird über --external:@aws-sdk/* aus dem Bundle herausgehalten, weil es in der Node18-Runtime ohnehin vorinstalliert ist. Es mit einzupacken würde das Paket nur unnötig aufblähen, ohne einen Vorteil zu bringen.
Wer mit AWS SAM oder dem CDK arbeitet, muss sich um den Aufruf oft nicht selbst kümmern: Das Construct NodejsFunction nutzt esbuild intern und bündelt automatisch. Der Punkt bleibt derselbe – weniger Code und weniger Dependencies bedeuten kürzeren Download und schnelleres Modul-Laden, und damit einen kürzeren Cold Start.
Der zweite Hebel: Init-Code an die richtige Stelle
Jetzt kommt der Teil, der in der Praxis den größten Unterschied macht und gleichzeitig am häufigsten falsch gemacht wird: Wo legst du deine Clients und Verbindungen an?
Alles, was du auf Modulebene außerhalb des Handlers schreibst, läuft genau einmal pro Execution Environment – in der Init-Phase. Bei jedem folgenden Warm Invoke wird das Ergebnis wiederverwendet. Genau dorthin gehört daher alles, was wiederverwendbar und über Aufrufe hinweg stabil ist: SDK-Clients, Datenbank-Pools, geparste Konfiguration.
// module scope — runs once per execution environment (Init phase)
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { GetItemCommand } from "@aws-sdk/client-dynamodb";
// created once, reused across every warm invoke
const client = new DynamoDBClient({});
export const handler = async (event: { id: string }) => {
const result = await client.send(
new GetItemCommand({
TableName: "orders",
Key: { id: { S: event.id } },
})
);
return { statusCode: 200, body: JSON.stringify(result.Item) };
};
Der DynamoDBClient wird hier einmal erzeugt und lebt danach in der Umgebung. Jeder warme Invoke greift auf dieselbe Instanz zu – inklusive der bereits aufgebauten HTTP-Verbindungen. Genau hier liegt ein oft übersehener Vorteil des AWS SDK v3: HTTP Keep-Alive ist standardmäßig aktiv. Die TCP- und TLS-Handshakes fallen also nur beim ersten Aufruf an und werden danach wiederverwendet.
Ein kurzer, aber praxisrelevanter Hinweis zur Migration: Beim älteren AWS SDK v2 war Keep-Alive nicht automatisch an. Dort musst du die Umgebungsvariable AWS_NODEJS_CONNECTION_REUSE_ENABLED=1 setzen (verfügbar ab v2.463.0), um dasselbe Verhalten zu bekommen. Wer noch v2-Code betreibt, sollte das prüfen – es ist eine der billigsten Latenzverbesserungen überhaupt.
Lazy-Init für das, was nicht jeder Invoke braucht
Nicht jede Ressource sollte in die Init-Phase. Wenn du eine teure Verbindung nur in einem von zwanzig Aufrufen tatsächlich brauchst, verlängert ihr Aufbau auf Modulebene jeden einzelnen Cold Start – auch die, die sie nie nutzen. Hier lohnt sich Lazy-Init: Die Ressource wird erst beim ersten tatsächlichen Bedarf erzeugt und danach für die Lebensdauer der Environment gecacht.
import { createPool, type Pool } from "./db";
let dbPromise: Promise<Pool> | undefined;
// created on first use, then cached for the environment's lifetime
const getDb = (): Promise<Pool> => (dbPromise ??= createPool());
export const handler = async (event: { needsDb: boolean }) => {
if (!event.needsDb) {
return { statusCode: 204 };
}
const db = await getDb();
const rows = await db.query("SELECT 1");
return { statusCode: 200, body: JSON.stringify(rows) };
};
Der ??=-Operator sorgt dafür, dass der Pool nur ein einziges Mal aufgebaut wird. Beim ersten Aufruf, der ihn wirklich braucht, entsteht eine kleine Verzögerung – dafür bleibt die Init-Phase schlank und schnell, und alle Invokes, die den Pool nicht benötigen, zahlen nichts dafür. Es ist die Balance zwischen "alles vorab" und "alles pro Aufruf", und die richtige Grenze hängt davon ab, wie oft eine Ressource tatsächlich gebraucht wird.
Der dritte Hebel: Memory ist auch CPU
Das ist die Stellschraube, die am meisten unterschätzt wird, weil sie missverständlich benannt ist. Der Konfigurationswert heißt MemorySize und lässt sich zwischen 128 MB und 10.240 MB in 1-MB-Schritten einstellen. Aber er steuert eben nicht nur den Speicher: CPU wird linear proportional zum Memory zugeteilt. Bei genau 1.769 MB bekommt deine Funktion eine vollständige vCPU; am oberen Ende stehen bis zu 6 vCPUs zur Verfügung.
Das hat unmittelbare Konsequenzen. Eine Funktion, die CPU-gebundene Arbeit leistet – JSON parsen, Kryptografie, Datenaufbereitung – wird mit mehr Memory schneller, obwohl sie den zusätzlichen Speicher gar nicht braucht. Und interessanterweise gilt das auch für den Cold Start selbst: Das Laden und Initialisieren der Runtime ist CPU-Arbeit, die von mehr zugeteilter Rechenleistung profitiert.
Der Fehler, den ich hier am häufigsten sehe, ist einer von zwei Extremen. Entweder bleibt die Funktion aus Sparsamkeit bei 128 MB und ist dadurch bei CPU-Last unnötig langsam – oder jemand schraubt das Memory blind auf ein hohes Niveau in der Hoffnung, dass "viel hilft viel". Beides ist Raten. Mehr Memory kostet mehr pro Millisekunde, und ab einem bestimmten Punkt bringt es keinen Latenzgewinn mehr, weil die Funktion nicht mehr CPU-, sondern I/O-gebunden ist. Dann wartest du auf eine Datenbank oder eine externe API, und da hilft keine zusätzliche vCPU.
Die einzige seriöse Antwort ist Messen. Für genau diesen Zweck gibt es das Open-Source-Tool AWS Lambda Power Tuning, das deine Funktion mit verschiedenen Memory-Werten durchprobiert und Kosten gegen Latenz aufträgt. Statt zu raten, siehst du die tatsächliche Kurve für deine konkrete Workload und wählst den Punkt, der zu deinem Ziel passt.
Der vierte Hebel: Provisioned Concurrency für planbare Latenz
Manchmal reicht es nicht, den Cold Start zu verkürzen – du willst ihn für bestimmte Funktionen ganz vermeiden. Das ist der Fall bei latenzkritischen, gut vorhersehbaren Lasten: eine Funktion hinter einem Login-Flow, ein Endpunkt, der werktags um neun Uhr eine planbare Lastspitze bekommt.
Dafür gibt es Provisioned Concurrency. Du konfigurierst über ProvisionedConcurrentExecutions eine Anzahl von Environments, die Lambda dauerhaft vor-initialisiert bereithält – mit bereits gelaufener Init-Phase. Aufrufe treffen dann auf eine warme Umgebung, ohne dass ein Cold Start entsteht. Wichtig ist das Detail, dass Provisioned Concurrency auf einem Alias oder einer konkreten Version konfiguriert wird, nicht auf $LATEST. Wer diese Environments zusätzlich an eine schwankende Last anpassen will, kann sie über Application Auto Scaling dynamisch skalieren.
Der Preis dafür ist wörtlich zu nehmen: Vorgehaltene Environments kosten auch dann, wenn sie im Leerlauf sind. Provisioned Concurrency ist deshalb kein Allheilmittel, das man über jede Funktion legt, sondern ein gezieltes Werkzeug für die wenigen Stellen, an denen planbare, niedrige Latenz den Aufpreis rechtfertigt. Für die meisten Hintergrund- und Event-getriebenen Funktionen ist der gelegentliche Cold Start völlig unkritisch – dort wäre Provisioned Concurrency verschenktes Geld.
Eine Randnotiz, weil die Frage in Reviews oft kommt: Für Java gibt es seit Ende 2022 zusätzlich Lambda SnapStart, das über einen Snapshot der initialisierten Umgebung arbeitet. Für Node.js ist das derzeit kein Thema – SnapStart ist zu diesem Zeitpunkt eine Java-only-Option. Für unsere TypeScript-Funktionen bleiben die Hebel oben die relevanten.
Messen, nicht vermuten
Der rote Faden durch all diese Hebel ist derselbe: Ohne Messung bleibt jede Optimierung ein Bauchgefühl. Zum Glück liefert Lambda die entscheidenden Zahlen frei Haus. Jeder Invoke schreibt eine REPORT-Zeile in die CloudWatch-Logs, und bei einem Cold Start enthält sie ein zusätzliches Feld:
{
"Duration": "42.11 ms",
"BilledDuration": "43 ms",
"MaxMemoryUsed": "98 MB",
"InitDuration": "312.44 ms"
}
Das Feld InitDuration erscheint ausschließlich bei einem Cold Start – es ist genau die Zeit für Download, Runtime-Init und deinen Function-Init-Code. Findest du diese Zeile in den Logs, weißt du zweierlei: Erstens war das ein Cold Start. Zweitens siehst du an der Zahl, wie teuer deine Init-Phase tatsächlich ist. Steigt InitDuration in Richtung Sekunden, ist das ein deutliches Signal, dass zu viel auf Modulebene passiert.
Wer tiefer schauen will, aktiviert AWS X-Ray. Dort taucht die Initialisierung als eigenes Subsegment im Trace auf, sauber getrennt von der Handler-Zeit. Das ist besonders wertvoll, wenn du herausfinden willst, ob deine Latenz aus dem Cold Start kommt oder aus einem langsamen Downstream-Aufruf im Handler – zwei völlig unterschiedliche Probleme, die im reinen Gesamtwert nicht auseinanderzuhalten sind.
Die Hebel im Überblick
Wenn ich die konkreten Stellschrauben zusammenfassen soll, ergibt sich eine kompakte Liste, die ich in Reviews gerne als Checkliste verwende:
- Bundle klein halten mit esbuild und Tree-Shaking, das in der Runtime vorhandene AWS SDK als
externalmarkieren. - Wenige Dependencies einsetzen und den Import-Graph bewusst schlank halten.
- Wiederverwendbare Clients und Verbindungen außerhalb des Handlers anlegen und über Warm Invokes wiederverwenden.
- Lazy-Init für teure, selten genutzte Ressourcen, damit die Init-Phase schlank bleibt.
- Memory als CPU-Regler verstehen und empirisch tunen – mit Lambda Power Tuning statt Bauchgefühl.
- Provisioned Concurrency gezielt für die wenigen latenzkritischen, planbaren Funktionen einsetzen.
- Init Duration in CloudWatch und X-Ray messen, statt zu vermuten.
Fazit
Cold Starts sind kein Makel, sondern eine Eigenschaft des Ausführungsmodells von Lambda – und genau deshalb sind sie steuerbar, sobald man das Modell versteht. Die Init-Phase läuft einmal pro Execution Environment, warme Invokes überspringen sie, und fast alles, was du an der Latenz drehen kannst, hängt daran, wie schwer oder leicht du diese Init-Phase machst.
Die wichtigste Einsicht aus meiner Praxis ist, dass es hier keine einzelne große Lösung gibt, sondern ein Zusammenspiel kleiner, verständlicher Entscheidungen: ein schlankes Bundle, Clients an der richtigen Stelle, ein sinnvoll gewählter Memory-Wert und – nur dort, wo es sich wirklich lohnt – Provisioned Concurrency. Und darüber steht immer das Messen. Wer die Init Duration im Blick behält, verwandelt das diffuse "manchmal lahm" in eine Zahl, an der man arbeiten kann. Das ist der eigentliche Gewinn: aus einem Bauchgefühl wird ein Engineering-Problem mit klaren Hebeln.
Wenn du an dieser Stelle einsteigst und das größere Bild suchst, lohnt sich der Blick in Serverless: Grundlagen und Grenzen sowie in den AWS-Serverless-Lernpfad, der die Bausteine rund um Lambda in einen Zusammenhang stellt.
Weiterführende Quellen
- Lambda execution environment lifecycle
- Configure function memory (Memory = CPU)
- Understanding and remediating cold starts (AWS Compute Blog)
- Operating Lambda: Performance optimization – Part 1
- Reuse connections with keep-alive (AWS SDK v3)
- Reuse connections with keep-alive (AWS SDK v2)
- Lambda function scaling and Provisioned Concurrency
Kommentare