El cliente pagaba 650 USD al mes a Cloudinary por transformaciones de imagen. Catálogo de e-commerce con 18 mil productos, cada uno con 4 variantes (thumbnail, mobile, desktop, zoom) y 3 formatos (jpg, webp, avif). Armé el mismo flujo con Lambda + Sharp + CloudFront y cuesta 55 USD al mes. Funciona igual de bien y es más flexible.
Acá va la implementación completa.
Arquitectura
flowchart LR
B[Browser] -->|/img/cat/800x600.webp| CF[CloudFront]
CF -->|cache miss| LF[Lambda@Edge<br/>viewer-request]
LF --> S3[S3 Transformed]
S3 -->|404| APIGW[API Gateway]
APIGW --> L[Lambda Sharp]
L --> SO[S3 Original]
L -->|put transformed| S3
L --> CF
S3 --> CF
CF --> B
La idea es generar transformaciones on-demand la primera vez que alguien las pide. El path de la URL describe la transformación: /img/product-123/800x600.webp. Si S3 ya tiene esa versión, CloudFront la sirve. Si no, Lambda la genera, la guarda en S3 y la devuelve.
URL pattern
Establecí convenciones en la URL para no pasar query params (que complican el caché):
/img/{originalKey}/{width}x{height}.{format}
/img/{originalKey}/{width}x{height}_q{quality}.{format}
/img/{originalKey}/{width}x{height}_fit_{mode}.{format}
Ejemplos:
/img/products/shoe-123.jpg/800x600.webp
/img/products/shoe-123.jpg/400x400_q85.avif
/img/products/shoe-123.jpg/1200x800_fit_cover.webp
CloudFront cachea por URL completa, así cada variante tiene su propia cache entry.
Lambda de transformación
// transformer/index.ts
import { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const ORIGINAL_BUCKET = process.env.ORIGINAL_BUCKET!;
const TRANSFORMED_BUCKET = process.env.TRANSFORMED_BUCKET!;
interface TransformParams {
originalKey: string;
width: number;
height: number;
format: 'webp' | 'avif' | 'jpeg' | 'png';
quality: number;
fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
}
function parsePath(path: string): TransformParams | null {
const match = path.match(
/^\/img\/(.+)\/(\d+)x(\d+)(?:_q(\d+))?(?:_fit_(\w+))?\.(webp|avif|jpe?g|png)$/
);
if (!match) return null;
const [, originalKey, w, h, q, fit, fmt] = match;
const width = parseInt(w, 10);
const height = parseInt(h, 10);
if (width < 1 || width > 4000 || height < 1 || height > 4000) return null;
return {
originalKey: decodeURIComponent(originalKey),
width,
height,
format: (fmt === 'jpg' ? 'jpeg' : fmt) as TransformParams['format'],
quality: q ? parseInt(q, 10) : 82,
fit: (fit || 'cover') as TransformParams['fit'],
};
}
async function transformImage(params: TransformParams): Promise<Buffer> {
const original = await s3.send(
new GetObjectCommand({
Bucket: ORIGINAL_BUCKET,
Key: params.originalKey,
})
);
const originalBuffer = Buffer.from(await original.Body!.transformToByteArray());
let pipeline = sharp(originalBuffer, { failOn: 'error' }).resize({
width: params.width,
height: params.height,
fit: params.fit,
withoutEnlargement: true,
});
switch (params.format) {
case 'webp':
pipeline = pipeline.webp({ quality: params.quality, effort: 4 });
break;
case 'avif':
pipeline = pipeline.avif({ quality: params.quality, effort: 4 });
break;
case 'jpeg':
pipeline = pipeline.jpeg({ quality: params.quality, progressive: true, mozjpeg: true });
break;
case 'png':
pipeline = pipeline.png({ compressionLevel: 9, palette: true });
break;
}
return pipeline.toBuffer();
}
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const path = event.rawPath;
const params = parsePath(path);
if (!params) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Invalid URL pattern' }),
};
}
try {
const transformed = await transformImage(params);
const transformedKey = path.substring(1);
await s3.send(
new PutObjectCommand({
Bucket: TRANSFORMED_BUCKET,
Key: transformedKey,
Body: transformed,
ContentType: `image/${params.format}`,
CacheControl: 'public, max-age=31536000, immutable',
})
);
return {
statusCode: 200,
headers: {
'Content-Type': `image/${params.format}`,
'Cache-Control': 'public, max-age=31536000, immutable',
},
body: transformed.toString('base64'),
isBase64Encoded: true,
};
} catch (err) {
console.error('Transform failed:', err);
if ((err as any).Code === 'NoSuchKey') {
return { statusCode: 404, body: 'Original not found' };
}
return { statusCode: 500, body: 'Transform error' };
}
};
El failOn: 'error' es importante. Sharp por defecto intenta procesar imágenes corruptas y puede tardar mucho o consumir memoria. Mejor fallar rápido y devolver 500.
El withoutEnlargement: true evita escalar imágenes pequeñas hacia arriba. Si la original es 500x500 y piden 1000x1000, devuelve 500x500. Los clientes pueden escalar con CSS.
Sharp en Lambda
Sharp tiene binarios nativos compilados por plataforma. En Lambda necesitas el build de Linux x64 o ARM64. El truco:
// package.json en el directorio del Lambda
{
"dependencies": {
"sharp": "0.33.2"
}
}
# build script
cd lambdas/transformer
rm -rf node_modules
npm install --os=linux --cpu=arm64 sharp
zip -r ../transformer.zip .
Si olvidas el --os=linux, sharp se instala para macOS y el Lambda falla con "something.node: invalid ELF header".
La otra opción es usar ARM64 Graviton en Lambda. Es 20% más barato y lo soporté sin problemas. architecture: Architecture.ARM_64 en CDK.
CDK stack completo
// cdk/image-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import { Construct } from 'constructs';
export class ImageOptimizationStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
const originalBucket = new s3.Bucket(this, 'OriginalImages', {
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
});
const transformedBucket = new s3.Bucket(this, 'TransformedImages', {
encryption: s3.BucketEncryption.S3_MANAGED,
lifecycleRules: [
{
id: 'expire-rare-variants',
prefix: 'img/',
transitions: [
{
storageClass: s3.StorageClass.INTELLIGENT_TIERING,
transitionAfter: cdk.Duration.days(1),
},
],
},
],
});
const transformerFn = new lambda.Function(this, 'TransformerFn', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('../lambdas/transformer.zip'),
architecture: lambda.Architecture.ARM_64,
memorySize: 1024,
timeout: cdk.Duration.seconds(15),
environment: {
ORIGINAL_BUCKET: originalBucket.bucketName,
TRANSFORMED_BUCKET: transformedBucket.bucketName,
},
});
originalBucket.grantRead(transformerFn);
transformedBucket.grantReadWrite(transformerFn);
const httpApi = new apigwv2.HttpApi(this, 'TransformerApi', {
defaultIntegration: new integrations.HttpLambdaIntegration(
'TransformerIntegration',
transformerFn
),
});
const distribution = new cloudfront.Distribution(this, 'ImageDistribution', {
defaultBehavior: {
origin: origins.OriginGroup.fromOrigins({
primaryOrigin: new origins.S3Origin(transformedBucket),
fallbackOrigin: new origins.HttpOrigin(
cdk.Fn.select(2, cdk.Fn.split('/', httpApi.apiEndpoint))
),
fallbackStatusCodes: [403, 404],
}),
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
compress: true,
},
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
});
new cdk.CfnOutput(this, 'ImageCdnUrl', {
value: distribution.distributionDomainName,
});
}
}
El OriginGroup es la magia. CloudFront intenta primero S3 (donde están los transformados). Si devuelve 404 o 403 (porque aún no existe), cae al API Gateway con el Lambda que genera la imagen.
La Lambda guarda el resultado en S3 mientras devuelve el response. La siguiente request para la misma URL va directo a S3, sin volver a invocar Lambda.
Componente Picture responsive
// components/SmartImage.tsx
interface SmartImageProps {
src: string;
alt: string;
widths: number[];
aspectRatio?: number;
className?: string;
priority?: boolean;
}
const CDN_BASE = 'https://images.ittal.co';
export function SmartImage({
src,
alt,
widths,
aspectRatio = 16 / 9,
className,
priority = false,
}: SmartImageProps) {
const buildUrl = (width: number, format: string) => {
const height = Math.round(width / aspectRatio);
return `${CDN_BASE}/img/${encodeURIComponent(src)}/${width}x${height}.${format}`;
};
const avifSrcSet = widths.map((w) => `${buildUrl(w, 'avif')} ${w}w`).join(', ');
const webpSrcSet = widths.map((w) => `${buildUrl(w, 'webp')} ${w}w`).join(', ');
const jpegSrcSet = widths.map((w) => `${buildUrl(w, 'jpeg')} ${w}w`).join(', ');
const sizes = `(max-width: 640px) 100vw, (max-width: 1024px) 50vw, ${Math.max(...widths)}px`;
return (
<picture className={className}>
<source type="image/avif" srcSet={avifSrcSet} sizes={sizes} />
<source type="image/webp" srcSet={webpSrcSet} sizes={sizes} />
<img
src={buildUrl(widths[0], 'jpeg')}
srcSet={jpegSrcSet}
sizes={sizes}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
width={widths[0]}
height={Math.round(widths[0] / aspectRatio)}
/>
</picture>
);
}
Uso:
<SmartImage
src="products/shoe-123.jpg"
alt="Zapatilla running"
widths={[400, 800, 1200, 1600]}
aspectRatio={1}
priority
/>
Genera 12 URLs (4 widths × 3 formatos). El navegador elige la mejor combinación según soporte y viewport. AVIF donde se puede, WebP como fallback, JPEG universal.
Comparativa de formatos
Tomé una foto de producto de 3000x2000 original en JPEG (1.2MB). Al redimensionar a 800x600:
| Formato | Calidad | Tamaño | Tiempo Lambda | Soporte |
|---|---|---|---|---|
| JPEG | 82 | 95 KB | 180 ms | 100% |
| WebP | 82 | 55 KB | 210 ms | 97% |
| AVIF | 82 | 30 KB | 850 ms | 88% |
AVIF tarda 4x más en generarse pero solo se genera una vez. Después S3 lo sirve instantáneo. El ahorro de bandwidth compensa el costo de Lambda.
Lo que aprendí
1. Memoria de Lambda importa más que tiempo.
Sharp usa mucha RAM para imágenes grandes. Con 512MB fallaba en imágenes 4K. Con 1024MB funciona. Cada duplicar de memoria también duplica CPU, así que 1024MB es 2x más rápido que 512MB aunque pagues el doble por ms.
2. Lambda cold start es brutal con Sharp.
Cold start con Sharp tarda 2-3 segundos (load del binario nativo). Con provisioned concurrency (2 instancias) bajé a 400ms. Para 30 USD/mes vale la pena en sitios con tráfico medio.
3. No optimices imágenes pequeñas.
Si la original pesa 20KB, no la transformes. El overhead de la Lambda (100ms) + el tiempo de red probablemente sean más que el benefit. Agregué un bypass en el Lambda: si originalSize < 50KB, devuelvo la original sin procesar.
4. Sharp tiene bug con EXIF orientation.
Fotos de iPhone vienen con orientation en EXIF (la foto real está rotada 90 grados, el metadata dice "rotate 270"). Sin .rotate() antes del resize, se ven giradas. La llamada .rotate() sin parámetros aplica el EXIF.
5. Lifecycle en S3 me ahorró 80% de storage.
Tenía 2TB de variantes transformadas, muchas generadas una sola vez para un bot que nunca volvió. Movi todo a Intelligent Tiering después de 1 día. Las variantes populares se quedan en Standard, las raras bajan a Infrequent Access automáticamente. Ahorro: 40 USD al mes.
Cuándo NO implementar esto tú mismo
Si tu volumen es pequeño (menos de 100 productos), Cloudinary Free o Imgix Free cubren sin problemas. El ROI de esta infra no existe para catálogos chicos.
Si tu equipo no tiene experiencia con Lambda, el debugging de Sharp puede ser frustrante. Los errores de binarios nativos son crípticos. Cloudinary paga por eliminarte ese dolor.
Si necesitas transformaciones avanzadas (face detection, auto crop inteligente, filtros AI), Cloudinary y Imgix tienen features que Sharp no. Para eso, paga el servicio.
El próximo artículo compara Route Handlers de Next.js 15 contra API Gateway. Cuándo cada uno gana, cuánto cuestan, y por qué a veces vale la pena combinarlos.
Top comments (0)