aryem.dev

Reducing Server Storage by 50% with Smart Image Resizing

Our S3 storage costs were spiraling out of control. Users were uploading 8MB phone photos when we only needed to display 200KB thumbnails. Storage grew from 500GB to 2.5TB in 6 months, costing $64/month and accelerating.

We built an automated image resizing service that:

Here’s the complete implementation.

The Problem

User behavior:

Impact:

The Solution Architecture

[User Upload]
     ↓
[API Gateway] → Validate & accept upload
     ↓
[S3: originals/] → Store original (immutable)
     ↓
[S3 Event Trigger]
     ↓
[Lambda: Image Processor]
  - Detect dimensions
  - Generate variants
  - Optimize formats
     ↓
[S3: optimized/] → Store processed images
  - thumbnails (150x150)
  - medium (800x600)
  - large (1920x1080)
  - WebP and JPEG versions
     ↓
[CloudFront CDN] → Serve to users

Implementation

Lambda Image Processor

import sharp from 'sharp';
import { S3 } from 'aws-sdk';

const s3 = new S3();

interface ImageVariant {
  suffix: string;
  width: number;
  height?: number;
  quality: number;
}

const VARIANTS: ImageVariant[] = [
  { suffix: 'thumbnail', width: 150, height: 150, quality: 85 },
  { suffix: 'small', width: 400, quality: 85 },
  { suffix: 'medium', width: 800, quality: 82 },
  { suffix: 'large', width: 1920, quality: 80 },
];

export async function handler(event: S3Event) {
  const bucket = event.Records[0].s3.bucket.name;
  const key = event.Records[0].s3.object.key;

  // Download original image
  const { Body } = await s3.getObject({ Bucket: bucket, Key: key }).promise();
  const imageBuffer = Body as Buffer;

  // Get image metadata
  const metadata = await sharp(imageBuffer).metadata();

  console.log(`Processing ${key}: ${metadata.width}x${metadata.height}, ${metadata.format}`);

  // Generate all variants
  await Promise.all([
    ...VARIANTS.map(variant => generateVariant(imageBuffer, variant, bucket, key)),
    generateWebPVersions(imageBuffer, bucket, key),
  ]);

  // Delete original from originals bucket (moved to optimized)
  // Or keep based on retention policy
  console.log(`Processed ${key} successfully`);
}

async function generateVariant(
  imageBuffer: Buffer,
  variant: ImageVariant,
  bucket: string,
  originalKey: string
): Promise<void> {
  const processor = sharp(imageBuffer);

  // Resize
  if (variant.height) {
    // Cover mode for thumbnails
    processor.resize(variant.width, variant.height, {
      fit: 'cover',
      position: 'center',
    });
  } else {
    // Maintain aspect ratio
    processor.resize(variant.width, undefined, {
      fit: 'inside',
      withoutEnlargement: true,
    });
  }

  // Optimize
  const optimized = await processor
    .jpeg({ quality: variant.quality, progressive: true })
    .toBuffer();

  // Generate key
  const outputKey = originalKey.replace(
    'originals/',
    `optimized/${variant.suffix}/`
  );

  // Upload to S3
  await s3.putObject({
    Bucket: bucket,
    Key: outputKey,
    Body: optimized,
    ContentType: 'image/jpeg',
    CacheControl: 'public, max-age=31536000', // Cache 1 year
  }).promise();

  const savingsPercent = ((1 - optimized.length / imageBuffer.length) * 100).toFixed(1);
  console.log(`  ${variant.suffix}: ${(optimized.length / 1024).toFixed(0)}KB (${savingsPercent}% smaller)`);
}

async function generateWebPVersions(
  imageBuffer: Buffer,
  bucket: string,
  originalKey: string
): Promise<void> {
  await Promise.all(
    VARIANTS.map(async variant => {
      const processor = sharp(imageBuffer);

      if (variant.height) {
        processor.resize(variant.width, variant.height, { fit: 'cover' });
      } else {
        processor.resize(variant.width, undefined, { fit: 'inside' });
      }

      const webp = await processor
        .webp({ quality: variant.quality - 5 }) // WebP slightly lower quality = same perceived quality
        .toBuffer();

      const outputKey = originalKey.replace(
        'originals/',
        `optimized/${variant.suffix}/`
      ).replace(/\.(jpg|jpeg|png)$/i, '.webp');

      await s3.putObject({
        Bucket: bucket,
        Key: outputKey,
        Body: webp,
        ContentType: 'image/webp',
        CacheControl: 'public, max-age=31536000',
      }).promise();
    })
  );
}

Client-Side: Responsive Images

<template>
  <picture>
    <!-- WebP for modern browsers -->
    <source
      type="image/webp"
      :srcset="`
        ${getImageUrl('thumbnail', 'webp')} 150w,
        ${getImageUrl('small', 'webp')} 400w,
        ${getImageUrl('medium', 'webp')} 800w,
        ${getImageUrl('large', 'webp')} 1920w
      `"
      sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
    />

    <!-- JPEG fallback -->
    <source
      type="image/jpeg"
      :srcset="`
        ${getImageUrl('thumbnail', 'jpg')} 150w,
        ${getImageUrl('small', 'jpg')} 400w,
        ${getImageUrl('medium', 'jpg')} 800w,
        ${getImageUrl('large', 'jpg')} 1920w
      `"
      sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
    />

    <!-- Default -->
    <img
      :src="getImageUrl('medium', 'jpg')"
      :alt="alt"
      loading="lazy"
      width="800"
      height="600"
    />
  </picture>
</template>

<script>
export default {
  props: ['imageId', 'alt'],
  methods: {
    getImageUrl(size, format) {
      return `https://cdn.example.com/optimized/${size}/${this.imageId}.${format}`;
    }
  }
}
</script>

Cost Optimization: Intelligent Lifecycle

Not all images need all variants. Implement smart generation:

interface ImageContext {
  type: 'avatar' | 'product' | 'gallery' | 'background';
  userId: string;
}

function determineVariants(context: ImageContext): ImageVariant[] {
  switch (context.type) {
    case 'avatar':
      // Only need small sizes
      return [
        { suffix: 'thumbnail', width: 150, height: 150, quality: 85 },
        { suffix: 'small', width: 400, height: 400, quality: 85 },
      ];

    case 'product':
      // Need medium and large for product pages
      return [
        { suffix: 'thumbnail', width: 150, height: 150, quality: 85 },
        { suffix: 'medium', width: 800, quality: 82 },
        { suffix: 'large', width: 1920, quality: 80 },
      ];

    case 'gallery':
      // Need all variants
      return VARIANTS;

    case 'background':
      // Only large, high quality
      return [
        { suffix: 'large', width: 1920, quality: 90 },
      ];
  }
}

Results

Storage Savings

Before:

After:

Savings: $33/month = $396/year (and growing with scale)

Performance Impact

Additional Benefits

Key Learnings

  1. Sharp is excellent: Fast, reliable, feature-rich image processing
  2. Lambda works well: Serverless scales with uploads automatically
  3. WebP adoption is high: 95%+ browsers support it now
  4. Responsive images are essential: Serve appropriate size for device
  5. Keep originals: Storage is cheap, re-processing is expensive

Cost Breakdown

Monthly costs:

Annual savings: $768

What’s Next

Future enhancements:


Optimizing image delivery in your application? Let’s discuss strategies for cost and performance. Connect on LinkedIn.