0%
Back to Blog
Handling Images in Development: From Upload to Optimized Delivery

Handling Images in Development: From Upload to Optimized Delivery

June 6, 2026
15 min read

Every web app deals with images. But most developers don't think twice about how those images are stored or delivered until their Lighthouse score tanks, Core Web Vitals fail, and mobile users are left downloading 4MB photos meant for a 4K monitor while their data plans suffer. This comprehensive guide walks you through the entire production-ready pipeline: picking a storage solution, understanding how a CDN changes the game, optimizing image delivery for every screen using Gumlet, implementing responsive images with srcset, caching strategies, and monitoring real-world performance.

The Image Problem: Why This Matters

Images typically account for 50-80% of a website's total bandwidth. A single unoptimized 5MB image can account for the entire data usage of your homepage. On a 4G connection (around 15 Mbps), that's a 3-second wait just for one image. On a 3G connection (around 1 Mbps), that's a 40-second wait. By contrast, an optimized 80KB version loads in under 50ms on 3G.

Beyond load time, unoptimized images directly impact your Core Web Vitals scores: Largest Contentful Paint (LCP) is delayed when hero images load slowly, Cumulative Layout Shift (CLS) happens when image dimensions aren't properly set, and First Input Delay (FID) can be affected when heavy images force the browser to work harder. All three are ranking factors in Google's search algorithm.

Step 1: Where Do Your Images Actually Live?

Before you can optimize anything, you need a place to put your raw files. You generally have two practical options, each with distinct tradeoffs:

Cloudinary is a hosted solution that handles storage, delivery, transformation, and CDN distribution all in one platform. You upload an image using their SDK and immediately get a public URL back. It's fantastic for getting moving quickly without infrastructure headaches.

  • Zero infrastructure setup - just sign up and start uploading
  • Built-in CDN - images are automatically served from 60+ edge locations worldwide
  • Integrated transformations - resize, format convert, and compress directly via URL without backend code
  • Media management dashboard - organize, tag, and search assets easily
  • Automatic format detection - serves WebP to modern browsers, JPEG to older ones, no extra configuration
  • Easy integration with Next.js via the next-cloudinary package
  • Pricing scales with usage - storage, bandwidth, and transformations all add up, can get expensive at scale
  • Vendor lock-in - moving off Cloudinary later means migrating all your URLs
  • Less control - transformations are limited to what Cloudinary explicitly supports
  • Rate limits on free tier - if you hit growth quickly, you may exceed free tier quotas
typescript
// Example: Uploading to Cloudinary from Next.js
import { CldUploadWidget } from 'next-cloudinary';

export function ImageUploader() {
  return (
    <CldUploadWidget
      uploadPreset="my_unsigned_preset"
      onSuccess={(result: any) => {
        console.log('Public ID:', result.event.public_id);
        console.log('Secure URL:', result.event.secure_url);
      }}
    >
      {({ open }) => (
        <button onClick={() => open()}>Upload Image</button>
      )}
    </CldUploadWidget>
  );
}

If you want full ownership of your infrastructure and the ability to write custom transformation logic, AWS S3 is the industry standard. You manage the bucket, permissions, versioning, and lifecycle rules. The best practice here is generating presigned URLs, which allows your frontend to upload images directly to S3, completely bypassing your server so your compute instances don't get bogged down handling file transfers.

  • Full ownership and control - nothing is hidden, you own the data completely
  • Presigned URLs enable direct browser-to-S3 uploads - server never touches the file
  • Flexible pricing model - pay only for storage and bandwidth used
  • Integrates with other AWS services - CloudFront (CDN), Lambda (serverless transformations), IAM (fine-grained permissions)
  • Version control - keep multiple versions of files, implement rollback strategies
  • Advanced features - bucket policies, CORS configuration, lifecycle rules for auto-deletion or archival
  • Setup complexity - you must configure IAM, CORS, bucket policies, and CDN separately
  • Image transformations require extra tools - S3 alone doesn't resize or convert formats; you need Lambda, Gumlet, or similar
  • Operational burden - you are responsible for security, backup strategies, and cost optimization
  • No built-in media management UI - you need custom tooling to browse and organize images
typescript
// Example: Generating an S3 Presigned URL from a Next.js API route
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'us-east-1' });

export async function POST(req: Request) {
  const { fileName, contentType } = await req.json();

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: `uploads/${Date.now()}-${fileName}`,
    ContentType: contentType,
    // Optional: add metadata for organization
    Metadata: {
      'uploaded-by': 'web-app',
      'upload-timestamp': new Date().toISOString(),
    },
  });

  // Generate a presigned URL valid for 10 minutes
  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 600 });

  return Response.json({ uploadUrl });
}

// Frontend code to use the presigned URL
async function uploadImage(file: File) {
  // Step 1: Get the presigned URL from your API
  const res = await fetch('/api/upload', {
    method: 'POST',
    body: JSON.stringify({
      fileName: file.name,
      contentType: file.type,
    }),
  });
  const { uploadUrl } = await res.json();

  // Step 2: Upload directly to S3 using the presigned URL
  await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type },
  });

  // Step 3: Now you have the public URL
  // Format: https://mybucket.s3.amazonaws.com/uploads/1234567890-filename.jpg
}

Step 2: Understanding CDNs and Their Role

Before discussing optimization, let's understand how delivery works at scale. Storing an image on a server in a specific location (say, us-east-1 in Virginia) means every user around the world must fetch that image from Virginia. If a user in London, Tokyo, or Sydney tries to load your site, the physical distance creates noticeable latency. A request to Virginia from Tokyo travels roughly 5,600 miles. Light travels through fiber optic cables at about 2/3 the speed of light, so that's a minimum 37ms of latency just for the request to reach the server. Add processing time, and the response back, and you're easily at 100ms+ per image.

This is where a CDN (Content Delivery Network) enters. A CDN is a globally distributed network of servers (called "edge servers" or "Points of Presence", POP). When a user in Tokyo requests an image, instead of fetching it from Virginia, the CDN serves it from a Tokyo edge server. If the Tokyo server doesn't have the image cached, it fetches it from your origin (Virginia) just once and caches it for future requests. The result: 10-20ms latency instead of 100ms+.

  • 1. User in Tokyo requests your image
  • 2. Request hits Tokyo CDN edge server
  • 3. Tokyo server checks its cache - miss (first request)
  • 4. Tokyo server fetches the image from origin (Virginia) once
  • 5. Tokyo server caches the image and serves it to the user
  • 6. Next 10,000 users in Tokyo region get the cached copy instantly
  • 7. Next user in London requests the same image
  • 8. London edge server does the same process independently
  • 9. Result: each region has its own cached copy, optimal for that geography

CDNs respect HTTP Cache-Control headers to determine how long to cache content. If your image has Cache-Control: public, max-age=31536000 (1 year), the CDN caches it for a year. If it has max-age=3600 (1 hour), the CDN checks the origin every hour for updates.

typescript
// In your Next.js API route or middleware, set proper cache headers
export async function GET(req: Request) {
  const response = new Response(imageBuffer);
  
  // Cache raw images for 1 year (they are immutable via hash in filename)
  response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
  
  // Or for dynamic content, cache for 1 day
  // response.headers.set('Cache-Control', 'public, max-age=86400');
  
  return response;
}

Step 3: Image Transformation and Optimization with Gumlet

Storage and delivery are only part of the solution. The real magic happens when you combine a CDN with real-time image optimization. Let's say you're building an e-commerce platform and have product photos shot at 5000x5000 resolution. On mobile, your product grid displays thumbnails at 200x200 pixels. If you serve the full 5000x5000 image and let CSS scale it down, you're wasting massive bandwidth. Gumlet acts as both a smart image processor and a CDN. You point Gumlet at your S3 bucket or origin, and it handles resizing, modern format conversion (WebP, AVIF), compression, and intelligent cropping entirely through URL parameters - no backend code required.

The magic of Gumlet is that the URL itself becomes your image transformation API. No need for backend endpoints; just append query parameters:

text
// Base URL pointing to your Gumlet source
https://myapp.gumlet.io/products/red-shoe-raw.jpg?w=800&h=800&format=webp&q=85

Parameters explained:
w=800      → Resize width to 800px (maintains aspect ratio unless crop specified)
h=800      → Set height to 800px
format=webp→ Convert to WebP format (fallback formats can be chained)
q=85       → Compress to 85% quality
ar=1:1     → Enforce 1:1 aspect ratio
c=thumb    → Intelligent crop mode (crops to content, not random edges)
  • w (width): Resize to exact width in pixels
  • h (height): Resize to exact height in pixels
  • ar (aspect ratio): Enforce aspect ratio like ar=16:9
  • format: Convert to webp, avif, png, jpg, etc.
  • q (quality): Compression level 1-100 (default 75)
  • c (crop): Modes like smart, thumb, face (intelligent crop)
  • bg (background): Fill color when using aspect ratio, e.g., bg=ffffff
  • auto=format,compress: Let Gumlet auto-select best format and compression for the browser
text
// Real-world examples:

// Thumbnail for product grid (200x200, aggressive compression)
https://myapp.gumlet.io/products/shoe.jpg?w=200&h=200&c=thumb&format=webp&q=70

// Hero image for desktop (1200px wide, auto-format for best browser)
https://myapp.gumlet.io/hero.jpg?w=1200&format=auto&q=80

// Mobile product image (100% width up to 600px)
https://myapp.gumlet.io/products/shoe.jpg?w=600&format=webp&q=75

// Avatar (fixed size, face-smart crop)
https://myapp.gumlet.io/users/avatar.jpg?w=128&h=128&c=face&format=webp&q=85

Here's where Gumlet's magic truly shines:

  • The first time a user's browser requests the exact URL https://myapp.gumlet.io/shoe.jpg?w=800&format=webp&q=85, Gumlet grabs the raw image from your S3 bucket.
  • It processes the image on-the-fly: resizing to 800px wide, converting to WebP, compressing at quality 85.
  • It caches that exact processed version on the CDN edge server closest to the user.
  • The next 10,000 users who request that exact same URL get the pre-processed, cached version instantly.
  • Your origin server is never hit again for that particular size/format combination.
  • Requests from different geographies hit their own regional edge caches, keeping latency minimal everywhere.

This is fundamentally different from serving a raw image and letting CSS scale it. The user downloads only the bytes they need, and after the first request globally, every subsequent request is served from a cache milliseconds away.

Step 4: Serving Responsive Images with srcset

Mobile users should not download the same resolution as desktop users. The srcset attribute on <img> tags tells the browser which image variants are available at different sizes, and the browser automatically chooses the right one based on the device's screen width and pixel density. This is the modern standard for responsive images and is fully supported in all major browsers.

html
<img
  src="https://myapp.gumlet.io/article.jpg?w=800&format=webp&q=80"
  srcset="
    https://myapp.gumlet.io/article.jpg?w=480&format=webp&q=80 480w,
    https://myapp.gumlet.io/article.jpg?w=800&format=webp&q=80 800w,
    https://myapp.gumlet.io/article.jpg?w=1200&format=webp&q=80 1200w,
    https://myapp.gumlet.io/article.jpg?w=1600&format=webp&q=80 1600w
  "
  sizes="
    (max-width: 600px) 100vw,
    (max-width: 1024px) 90vw,
    1200px
  "
  alt="Article cover image"
  loading="lazy"
/>
  • src: Fallback image for old browsers
  • srcset: List of image URLs with width descriptors (480w, 800w, etc.)
  • sizes: CSS-like media queries telling the browser what size the image will be rendered at different screen sizes
  • loading="lazy": Defers loading until the image is near the viewport (huge performance win)
  • alt: Accessibility and SEO

When you load a page with responsive images, the browser:

  • 1. Reads the sizes attribute to know the image will be 100% viewport width on mobile, 90vw on tablet, 1200px on desktop
  • 2. Reads the current device viewport width and pixel density (e.g., 390px wide, 2x retina)
  • 3. Calculates the final rendered size: 390px * 2 = 780px effective width needed
  • 4. Looks at srcset and picks the smallest image that is >= 780px (in this case, the 800w variant)
  • 5. Downloads only that variant, saving bandwidth compared to always downloading the 1600w version
typescript
// components/ResponsiveImage.tsx
interface ResponsiveImageProps {
  src: string;
  alt: string;
  basePath: string; // e.g., 'article-title'
  lazy?: boolean;
}

export function ResponsiveImage({ src, alt, basePath, lazy = true }: ResponsiveImageProps) {
  const gumletBase = 'https://myapp.gumlet.io';
  
  return (
    <img
      src={`${gumletBase}/${basePath}?w=800&format=webp&q=80`}
      srcSet={`
        ${gumletBase}/${basePath}?w=480&format=webp&q=80 480w,
        ${gumletBase}/${basePath}?w=800&format=webp&q=80 800w,
        ${gumletBase}/${basePath}?w=1200&format=webp&q=80 1200w
      `}
      sizes="(max-width: 768px) 100vw, 800px"
      alt={alt}
      loading={lazy ? 'lazy' : 'eager'}
    />
  );
}

Step 5: Centralized Image Configuration

Instead of hardcoding pixel values (like w=800 or w=480) randomly throughout your components, create a central configuration. This acts as a t-shirt sizing chart for your UI and makes future layout changes trivial.

typescript
// lib/imageConfig.ts
export const IMAGE_SIZES = {
  avatar: { xs: 40, sm: 64, md: 96 },
  thumbnail: { xs: 120, sm: 200, md: 300 },
  card: { xs: 280, sm: 400, md: 500 },
  articleHero: { xs: 480, sm: 800, md: 1200 },
  fullWidth: { xs: 480, sm: 768, md: 1024, lg: 1280, xl: 1600 },
} as const;

export const GUMLET_BASE = 'https://myapp.gumlet.io';
export const QUALITY_PRESET = {
  low: 60,      // Thumbnails, low priority
  medium: 75,   // Standard images
  high: 85,     // Hero images, product photos
  max: 90,      // Critical images
} as const;

// Helper function to generate srcset
export function generateSrcSet(
  path: string,
  sizes: number[],
  format: 'webp' | 'auto' = 'webp',
  quality: number = 75
) {
  return sizes
    .map(
      (size) =>
        `${GUMLET_BASE}/${path}?w=${size}&format=${format}&q=${quality} ${size}w`
    )
    .join(',');
}

// Helper function to generate sizes attribute
export function generateSizes(breakpoints: Record<string, string>) {
  return Object.entries(breakpoints)
    .map(([media, size]) => `(max-width: ${media}) ${size}`)
    .join(',');
}

// Example usage:
const heroSrcSet = generateSrcSet('hero.jpg', [480, 800, 1200, 1600], 'webp', 85);
const heroSizes = generateSizes({
  '640px': '100vw',
  '1024px': '80vw',
  '9999px': '1200px',
});

Step 6: Caching Strategies and Headers

  • Browser cache: Stores images on the user's device. Controlled by Cache-Control headers.
  • CDN cache: Stores images on global edge servers. Also controlled by Cache-Control but with longer TTLs.
  • Origin cache: If you use CloudFront or similar, this is caching at the edge closest to your origin.
typescript
// Cache-Control strategies based on content type

// Raw images stored with content-addressed paths (hash in filename)
// These NEVER change, so cache forever
Cache-Control: public, max-age=31536000, immutable
// 31536000 seconds = 1 year
// 'immutable' tells browsers not to revalidate even if stale

// Images served through Gumlet URL parameters (resized, format-converted)
// These are re-created by Gumlet, so cache for a long time
Cache-Control: public, max-age=2592000
// 2592000 seconds = 30 days
// If you update Gumlet parameters, users won't see changes for 30 days

// Dynamic content (metadata, etc.)
Cache-Control: public, max-age=3600
// 1 hour - balance between freshness and hit rate

// Sensitive/user-specific content
Cache-Control: private, max-age=0, must-revalidate
// Not cached by CDN, must revalidate with origin

When you need to update an image, you can't rely on HTTP caching to invalidate it immediately. Instead, change the filename:

typescript
// Bad: Same URL, but you changed the file
// If cached for 1 year, users won't see the update for a year
myapp.gumlet.io/hero.jpg

// Good: Content-addressed filename (hash of content)
// Automatically busts cache when file changes
myapp.gumlet.io/hero-abc123def456.jpg

// When you update the image, the hash changes
myapp.gumlet.io/hero-xyz789abc123.jpg

// Tool: Use Next.js Image Optimization
// Automatically generates content-addressed filenames
import Image from 'next/image';
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  // Next.js internally generates something like:
  // /_next/image?url=%2Fhero.jpg&w=1200&q=75
/>

Step 7: Monitoring Image Performance

  • Largest Contentful Paint (LCP): Time until the largest visible element (usually a hero image) loads. Target: < 2.5 seconds.
  • Cumulative Layout Shift (CLS): How much the page layout moves around. Caused by missing image dimensions. Target: < 0.1
  • Image file size: Monitor average image sizes served. Aim to reduce byte count month-over-month.
  • Cache hit ratio: What percentage of requests are served from cache vs origin?
  • Time to First Byte (TTFB): How long until the server responds. Good CDNs should have TTFB < 200ms globally.
  • Google PageSpeed Insights: Free, runs real-world metrics from Chrome User Experience Report
  • WebPageTest: Detailed waterfall charts, test from different geographies
  • Gumlet Analytics: Shows cache hit ratio, bandwidth saved, formats served
  • Cloudinary Analytics: Upload statistics, transformation data, bandwidth usage
  • Next.js Analytics (with Vercel): Real user monitoring built in
html
<!-- WRONG: No dimensions, browser doesn't reserve space -->
<!-- Causes layout shift when image loads -->
<img src="image.jpg" alt="Photo" />

<!-- CORRECT: Always specify width and height -->
<!-- Browser reserves space, no layout shift -->
<img src="image.jpg" alt="Photo" width="800" height="600" />

<!-- CORRECT: CSS aspect ratio (modern approach) -->
<img
  src="image.jpg"
  alt="Photo"
  style="aspect-ratio: 4 / 3"
/>

<!-- React: Use Next.js Image component -->
import Image from 'next/image';
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
/>
// Next.js automatically adds proper spacing and dimensions

Step 8: Best Practices Checklist

  • Always specify width and height (or aspect-ratio in CSS) to prevent layout shift
  • Use lazy loading (loading="lazy") for images below the fold
  • Serve WebP and AVIF to modern browsers, JPEG to older ones (or use auto format)
  • Generate srcset with multiple sizes; let the browser choose
  • Cache aggressively: 1 year for content-addressed files, 30 days for transformations
  • Compress images to 70-85% quality (visual difference is imperceptible)
  • Use a CDN for global delivery; single-region hosting is no longer acceptable
  • Monitor Core Web Vitals monthly and set reduction targets
  • Use centralized image configuration to avoid magic numbers scattered throughout code
  • Set proper Cache-Control headers on your origin
  • Use semantic alt text for accessibility and SEO
  • Consider next-gen formats like AVIF, which compress 20-30% better than WebP

Putting It All Together: The Complete Mental Model

  • Upload: User selects a file, your frontend gets a presigned S3 URL, raw image goes directly to S3 (server never touched it)
  • Storage: S3 acts as the permanent, immutable source of truth with content-addressed filenames
  • Serving: Frontend generates Gumlet URLs with size/format parameters based on device capabilities
  • CDN Cache: Gumlet pulls the raw image once from S3, processes it on-the-fly (resize, convert, compress)
  • Edge Distribution: The processed image is cached on edge servers globally
  • Browser Cache: Browser caches the Gumlet-processed image with 1-year Cache-Control headers
  • User Delivery: Subsequent requests within that cache window serve the image in < 50ms globally

An unoptimized 5MB image costs you bandwidth, server capacity, and user experience. The same image optimized to 80KB costs 60x less to serve, loads 60x faster, and ranks better in search. The infrastructure cost of proper image handling is negligible compared to the compounding benefits of better performance, better SEO, and happier users.

Common Gotchas and Troubleshooting

You set Cache-Control: max-age=31536000, which means the browser and CDN cache for 1 year. If you reused the same filename, clients see the old version. Solution: Always use content-addressed filenames (hash the content), so updating the file changes the URL automatically.

If you changed a URL from ?format=jpg to ?format=webp but still see JPEGs, the old URL may still be cached at the CDN edge. Try adding a cache-busting query parameter: ?format=webp&v=2. Or wait for the cache to expire.

Ensure you are setting width/height on the <img> tag itself, not the parent container. The img element needs to know its own intrinsic ratio to reserve space correctly.

If the user takes 15 minutes to select and upload a file, and the presigned URL is only valid for 10 minutes, the upload will fail. Generate URLs with expiresIn: 3600 (1 hour) to give users plenty of time.

Final Thoughts

Image handling is not glamorous, but it's foundational. A site that loads images correctly and responsively beats competitors who don't, in every measurable way: search rankings, user retention, conversion rates. The toolchain (S3 or Cloudinary, CDN, Gumlet, srcset, caching headers) is now standardized. Implement it early, monitor it constantly, and your users will thank you.

This post was written with AI assistance to explore the topic more thoroughly. If something looks conceptually off, I'd genuinely appreciate a heads up - feel free to reach out.Contact

#images#cloudinary#s3#gumlet#cdn#performance#web-dev#optimization#responsive-images
.  .  .
Bhavi's SignatureBhavishya's Portfolio

Built with ❤️ by Bhavishya © 2026. All rights reserved.

Designed and developed by me using Next.js, Tailwind CSS, and a sprinkle of magic.
Components by Aceternity UI. Inspired by Ram's Portfolio.