<!-- Canonical: https://lucioduran.com/es/blog/serverless-cold-starts-optimization-strategies
Language: es
Content-SHA256: 4373caef0693d2f6a5f9414cec831d88a9116f41d8a98ff62fc8b134197bf60e
-->

Serverless

# Cold Starts en Serverless: Guía Completa de Estrategias de Optimización

2025-10-039 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies>)

## Entendiendo qué se está midiendo realmente

Antes de optimizar nada, es esencial entender la anatomía de un cold start. Un cold start de Lambda tiene tres fases bien distintas:

1. **Init de infraestructura**, AWS provisiona una microVM (Firecracker), descarga el paquete de deploy de S3 y arma el entorno de ejecución. Sobre esta fase no hay ningún control.
2. **Init de runtime**, El runtime del lenguaje arranca. Para Node.js, V8 se inicia y parsea el código. Para Java, la JVM carga.
3. **Init de aplicación**, El módulo handler se carga, corren los inicializadores estáticos, se establecen conexiones.

La mayoría de la gente mezcla las tres y después se pregunta por qué su "optimización" no movió la aguja. Solo es posible afectar las fases 2 y 3 directamente.

Así se extraen mediciones limpias de CloudWatch Logs Insights:

```sql
filter @type = "REPORT"
| parse @log /\/aws\/lambda\/(?<functionName>[^\/]+)/
| stats
 avg(@initDuration) as avg_init,
 pct(@initDuration, 50) as p50_init,
 pct(@initDuration, 99) as p99_init,
 count(*) as cold_starts
 by functionName
| sort avg_init desc
```

Esto devuelve el ` Init Duration ` que cubre fases 2 y 3 pero no la fase 1. La fase 1 son típicamente 100-400ms y no hay nada que se pueda hacer al respecto excepto usar provisioned concurrency.

## Provisioned concurrency: la garantía cara

Provisioned concurrency es la solución de fuerza bruta. Se le indica a [AWS](<https://aws.com/>) "mantener N entornos calientes todo el tiempo" y así lo hacen. Los cold starts desaparecen para esas N ejecuciones concurrentes.

```yaml
# serverless.yml
functions:
 checkout:
 handler: src/checkout.handler
 runtime: nodejs20.x
 memorySize: 1024
 provisionedConcurrency: 50
 events:
 - http:
 path: /checkout
 method: post
```

La matemática es directa pero significativa. A 1024MB de memoria en us-east-1, provisioned concurrency cuesta aproximadamente $0.0000041667 por GB-segundo. Para 50 instancias provisionadas corriendo 24/7:

```
50 instancias * 1 GB * 86400 seg/día * 30 días * $0.0000041667/GB-seg = ~$540/mes
```

Son $540 por mes solo para mantener funciones calientes, antes de cualquier costo de invocación. Para una función de checkout que maneja alrededor de 200 requests concurrentes en pico, provisionar 200 instancias costaría $2.160/mes. Se necesita un enfoque más eficiente.

### Application Auto Scaling para provisioned concurrency

La técnica comúnmente pasada por alto es combinar provisioned concurrency con Application Auto Scaling. Se establece una base y se deja que escale:

```bash
aws application-autoscaling register-scalable-target \
 --service-namespace lambda \
 --resource-id function:checkout-prod:checkout-prod-alias \
 --scalable-dimension lambda:function:ProvisionedConcurrency \
 --min-capacity 10 \
 --max-capacity 200

aws application-autoscaling put-scaling-policy \
 --service-namespace lambda \
 --resource-id function:checkout-prod:checkout-prod-alias \
 --scalable-dimension lambda:function:ProvisionedConcurrency \
 --policy-name checkout-scaling \
 --policy-type TargetTrackingScaling \
 --target-tracking-scaling-policy-configuration '{
 "TargetValue": 0.7,
 "PredefinedMetricSpecification": {
 "PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
 },
 "ScaleInCooldown": 300,
 "ScaleOutCooldown": 60
 }'
```

Target 70% de utilización. Escalar para arriba rápido (60s de cooldown), para abajo lento (300s). Este enfoque puede reducir el costo a más o menos $180/mes cubriendo el 95% de los picos de tráfico sin cold starts. El 5% restante, ráfagas súbitas e impredecibles, sigue sufriendo cold starts. Ahí es donde las optimizaciones de runtime importan.

## SnapStart: la respuesta de Java (finalmente)

Para Java en Lambda, SnapStart es transformacional. Toma un snapshot de la microVM Firecracker después de que la fase de inicialización termina, y después restaura desde ese snapshot en vez de bootear la JVM de cero.

Al migrar una función Spring Boot 3.2 del runtime estándar Java 21 a SnapStart, los resultados son significativos:

| Métrica | Sin SnapStart | Con SnapStart |
| --- | --- | --- |
| p50 cold start | 4.200ms | 620ms |
| p99 cold start | 8.100ms | 1.100ms |
| Memoria en init | 512MB | 512MB |

Eso es una reducción del 85% en p50. Pero hay caveats que los blog posts de AWS convenientemente se saltean.

### El problema de unicidad

SnapStart restaura desde un snapshot congelado. Eso significa que todo lo que se inicializó antes del snapshot, generadores de números random, UUIDs, tokens de conexión, será idéntico entre todas las instancias restauradas. AWS provee hooks de runtime para manejar esto:

```java
import software.amazon.lambda.snapstart.SnapshotRestore;

public class CheckoutHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

 private SecureRandom secureRandom;
 private HikariDataSource dataSource;

 public CheckoutHandler() {
 // Esto corre ANTES del snapshot
 dataSource = initializeDataSource();

 // Registrar hook de restore
 SnapshotRestore.registerRestoreHook(() -> {
 // Esto corre DESPUÉS del restore, en cada cold start
 secureRandom = new SecureRandom();
 secureRandom.nextBytes(new byte[32]); // Forzar re-seeding

 // Re-establecer conexiones a la base
 if (dataSource != null) {
 dataSource.getHikariPoolMXBean().softEvictConnections();
 }
 });
 }

 // ...
}
```

### Compatibilidad con CRaC

Por debajo, SnapStart usa la API CRaC (Coordinated Restore at Checkpoint) de OpenJDK. Al escribir hooks de restore contra la API CRaC directamente, funcionan tanto en SnapStart como en deploys CRaC standalone:

```java
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;

public class ConnectionManager implements Resource {

 public ConnectionManager() {
 Core.getGlobalContext().register(this);
 }

 @Override
 public void beforeCheckpoint(Context<? extends Resource> context) {
 // Cerrar conexiones antes del snapshot
 connectionPool.close();
 }

 @Override
 public void afterRestore(Context<? extends Resource> context) {
 // Re-establecer después del restore
 connectionPool = createPool();
 }
}
```

## LLRT: cuando se abandona el JIT por completo

LLRT (Low Latency Runtime) es el runtime experimental de JavaScript de Amazon construido sobre QuickJS en vez de V8. La idea clave: V8 gasta tiempo significativo de cold start en warmup de compilación JIT. QuickJS es un intérprete puro, sin JIT, sin tiers de compilación, sin warmup. El trade-off es throughput bruto por velocidad de startup.

Al benchmarkear una función API moderadamente compleja (parsing de JSON, llamadas a DynamoDB, transformación de respuesta) entre runtimes, se observa lo siguiente:

```
Node.js 20 estándar:
 Cold start: 340ms
 Invocación warm: 12ms

LLRT v1.2:
 Cold start: 48ms
 Invocación warm: 28ms
```

El cold start baja un 86%, pero las invocaciones warm son 2.3x más lentas. Para funciones que se inicializan seguido pero corren poco (API gateways, webhooks, procesadores de eventos), LLRT gana claramente. Para funciones compute-heavy que se mantienen warm, se pierde rendimiento.

Deployar LLRT requiere un custom runtime layer:

```dockerfile
FROM public.ecr.aws/awsguru/llrt:v1.2-arm64 as llrt

FROM public.ecr.aws/lambda/provided:al2023-arm64

COPY --from=llrt /usr/local/bin/llrt /var/runtime/bootstrap
COPY dist/ /var/task/

CMD ["index.handler"]
```

Un gotcha crítico: LLRT no soporta toda la API de Node.js. Sin módulo ` fs ` (usar el workaround de ` /tmp `), ` crypto ` limitado (sin ` createCipheriv `), sin módulos nativos. Es importante verificar la matriz de compatibilidad antes de comprometerse.

## V8 snapshots: el approach DIY

V8 tiene un mecanismo de snapshot incorporado que serializa el estado del heap después de la inicialización. Cloudflare Workers usa esto extensivamente, es la razón por la que los cold starts de Workers son básicamente cero. La misma técnica puede aplicarse con custom runtimes.

La idea: ejecutar el código de inicialización, serializar el heap de V8 a un blob binario, y en el cold start deserializar el blob en vez de re-ejecutar la inicialización. El flujo conceptual usando ` v8.startupSnapshot `:

```javascript
// snapshot-builder.mjs, correr en build time
const v8 = require('node:v8');

v8.startupSnapshot.setDeserializeMainFunction((data) => {
 // Esto corre en el restore
 const { handler } = require('./dist/handler');
 module.exports = { handler };
});

// Pre-inicializar objetos caros
const config = require('./config');
const routes = require('./routes');
const validators = require('./validators');

// Estos objetos ahora están en el snapshot
v8.startupSnapshot.addSerializeCallback(() => {
 console.log('Serializando snapshot del heap V8...');
});
```

Construir el snapshot:

```bash
node --snapshot-blob=snapshot.blob --build-snapshot snapshot-builder.mjs
```

Y arrancarlo:

```bash
node --snapshot-blob=snapshot.blob index.mjs
```

En pruebas, esto redujo la fase de inicialización de Node.js de 180ms a 35ms para una función con carga pesada de módulos. Combinado con LLRT o el runtime estándar de Node.js, es una técnica poderosa. La contra es la complejidad del build y el hecho de que no todo se serializa limpiamente (Promises, handles abiertos, etc.).

## La metodología correcta: cómo benchmarkear bien

Cada artículo sobre cold starts muestra un solo número. Ese número no significa nada sin contexto. A continuación, una metodología robusta de benchmarking:

```python
#!/usr/bin/env python3
"""Harness de benchmark de cold starts"""

import boto3
import time
import json
import statistics
from concurrent.futures import ThreadPoolExecutor

lambda_client = boto3.client('lambda', region_name='us-east-1')

def force_cold_start(function_name: str) -> dict:
 """Actualizar una env var para forzar nuevos entornos de ejecución"""
 lambda_client.update_function_configuration(
 FunctionName=function_name,
 Environment={'Variables': {'BENCH_TS': str(time.time_ns())}}
 )
 waiter = lambda_client.get_waiter('function_updated_v2')
 waiter.wait(FunctionName=function_name)
 time.sleep(2)

 start = time.perf_counter_ns()
 response = lambda_client.invoke(
 FunctionName=function_name,
 Payload=json.dumps({"benchmark": True}),
 LogType='Tail'
 )
 end = time.perf_counter_ns()

 import base64
 log_result = base64.b64decode(response['LogResult']).decode()
 init_duration = None
 for line in log_result.split('\n'):
 if 'Init Duration' in line:
 init_duration = float(line.split('Init Duration: ')[1].split(' ms')[0])

 return {
 'e2e_ms': (end - start) / 1_000_000,
 'init_duration_ms': init_duration,
 }

def benchmark(function_name: str, iterations: int = 30):
 results = []
 for i in range(iterations):
 result = force_cold_start(function_name)
 results.append(result)
 print(f" [{i+1}/{iterations}] init={result['init_duration_ms']:.1f}ms e2e={result['e2e_ms']:.1f}ms")

 init_durations = [r['init_duration_ms'] for r in results if r['init_duration_ms']]
 print(f"\n--- {function_name} ({len(init_durations)} muestras) ---")
 print(f" p50: {statistics.median(init_durations):.1f}ms")
 print(f" p90: {sorted(init_durations)[int(len(init_durations)*0.9)]:.1f}ms")
 print(f" p99: {sorted(init_durations)[int(len(init_durations)*0.99)]:.1f}ms")
 print(f" mean: {statistics.mean(init_durations):.1f}ms")
 print(f" std: {statistics.stdev(init_durations):.1f}ms")

if __name__ == '__main__':
 benchmark('checkout-prod', iterations=30)
```

Puntos clave: mínimo 30 iteraciones, forzar cold starts actualizando variables de entorno, medir ` Init Duration ` de la línea REPORT (no la latencia end-to-end que incluye red), y siempre reportar percentiles, los promedios son engañose es para distribuciones bimodales.

## La matriz de decisión

Después de un benchmarking exhaustivo, emerge el siguiente framework de decisión:

**Usar provisioned concurrency cuando**: se necesitan SLAs de latencia garantizada, el costo se justifica por el impacto de negocio, y el tráfico es medianamente predecible.

**Usar SnapStart cuando**: el runtime es Java/Kotlin, las restricciones de unicidad son manejables, y la inicialización es dominada por la JVM.

**Usar LLRT cuando**: el runtime es Node.js, las funciones son de corta vida y IO-bound, y no se necesita compatibilidad total con la API de Node.js.

**Usar V8 snapshots cuando**: hay inicialización pesada de módulos, se acepta invertir en complejidad del build pipeline, y se necesita control granular.

**Combinar enfoques**: Provisioned concurrency + SnapStart. LLRT + dependencias mínimas. V8 snapshots + tree shaking. Las técnicas son complementarias, no mutuamente excluyentes.

Como ejemplo práctico, una función de checkout puede beneficiarse de provisioned concurrency (auto-escalado, 10-200) más tree-shaking agresivo de dependencias que reduce el bundle de 12MB a 1.8MB. Los cold starts bajan de 1.247ms a 180ms, y provisioned concurrency atrapa la carga predecible. Los picos impredecibles sufren un cold start de 180ms en vez de 1.247ms, una mejora significativa para workloads sensibles a latencia.

## Preguntas frecuentes

### ¿Qué es un cold start en serverless?

Un cold start es el tiempo de inicialización que una función serverless tarda cuando no hay un entorno de ejecución warm disponible. Incluye descargar el paquete de deploy, iniciar el runtime e inicializar el código de la aplicación.

### ¿Provisioned concurrency elimina los cold starts completamente?

Sí, provisioned concurrency mantiene entornos pre-inicializados, así que los requests nunca sufren latencia de inicialización. Sin embargo, se paga por capacidad ociosa, y si el tráfico supera el count provisionado, los cold starts vuelven para el overflow.

### ¿LLRT está listo para producción?

LLRT es estable para la mayoría de workloads Node.js pero no soporta toda la superficie de API de Node.js. Elimina el compilador JIT a favor de startup rápido, así que funciones compute-heavy de larga duración pueden rendir peor que con runtimes Node.js estándar.

## Tips

1. **Tip:** Nunca benchmarkear cold starts desde una laptop. Usar trazas de ` X-Ray ` o las líneas ` REPORT ` de CloudWatch, el campo ` Init Duration ` es la única fuente de verdad disponible.
2. **Tip:** En Java, activar ` SnapStart ` Y limpiar el classpath. SnapStart solo ahorra ~70%, pero un classpath inflado serializa cientos de megas en el snapshot.
3. **Tip:** Configurar ` JAVA_TOOL_OPTIONS=-XX:+TieredCompilation -XX:TieredStopAtLevel=1 ` incluso con SnapStart, el JIT sigue activándose después del restore, y la compilación tier-1 alcanza para la mayoría de los workloads en Lambda.

- serverless
- cold-starts
- aws-lambda
- provisioned-concurrency
- snapstart
- llrt
- v8-snapshots
- performance

[Volver al Blog](<https://lucioduran.com/es/blog>)
