Efficient image hosting: CDNs & processing
Static delivery and image transformation solve different problems. A static CDN caches files that
already exist; it does not resize a JPEG simply because you append ?width=800. This guide combines
prebuilt variants, private origin storage and public CDN delivery.
Introduction to image hosting with static CDNs
Generate the variants your pages need during publishing, upload them under immutable filenames and let the CDN deliver them. A separate transformation origin is useful when dimensions cannot be known in advance, but it needs validation, resource limits and variant-aware caching.
Why use static CDNs for image hosting?
Caching can reduce repeat origin traffic and improve delivery for geographically distributed users. Prebuilding avoids runtime decoder work on every new image request. The tradeoff is storage and publishing time for variants that may never be requested.
Key benefits of static CDNs for developers
Immutable variants make debugging concrete: a URL identifies an actual file with known dimensions, format and contents. They also avoid conflating image generation failures with delivery failures.
Set up an image hosting CDN: step-by-step guide
For S3 and CloudFront, follow the current AWS private-origin setup:
- Create an S3 bucket with Block Public Access enabled and bucket-owner-enforced object ownership.
- Add its regular S3 bucket endpoint as a CloudFront origin, not its website endpoint.
- Attach an origin access control (OAC) that always signs requests.
- Grant the CloudFront service principal read access through a bucket policy scoped to your distribution ARN.
- Require HTTPS for viewers. Test the CloudFront hostname first; a custom hostname also needs its certificate and distribution alias, not just a DNS record.
- Upload approved public variants, then verify that CloudFront can read them while anonymous S3 access remains denied.
OAC protects the origin connection. It does not authenticate people visiting a public CloudFront URL.
Integrate on-the-fly image processing: save time and bandwidth
Choose between prebuilt variants and a transformation service deliberately. If you need arbitrary resizing, use a processing origin or a service such as Transloadit's Smart CDN. Verify that service's parameter and signing contract; static S3/CloudFront objects do not interpret transformation query parameters.
Example: responsive prebuilt images
For this example, prepare opaque input with at least 1280 pixels of width after orientation. The script below creates three WebP files at publishing time. These are real files, not imaginary dynamic CDN URLs:
<picture>
<source
type="image/webp"
srcset="/images/photo-320-v1.webp 320w, /images/photo-640-v1.webp 640w, /images/photo-1280-v1.webp 1280w"
sizes="(max-width: 640px) 100vw, 640px"
>
<img
id="hero"
src="/images/photo-640-v1.jpg"
alt="A description of your photograph"
width="640"
height="360"
style="max-width: 100%; height: auto"
>
</picture>
Replace the example URLs with your CDN hostname and set width and height to the actual
fallback dimensions printed by the script. The sizes value must match the image's layout.
JPEG is the fallback for browsers that do not select WebP.
Tools and libraries for image processing
Sharp (Node.js)
Install sharp@0.35.4 with Node.js 24 or newer. Put this in build-images.mjs, provide your own
photo.jpg, and run node build-images.mjs. This is a trusted publishing script, not an upload
endpoint:
import { mkdir, open, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
async function main() {
const file = await open('photo.jpg', 'r')
let source
try {
const stat = await file.stat()
if (!stat.isFile() || stat.size < 1 || stat.size > 8 * 1024 * 1024) {
throw new Error('Use a regular source image no larger than 8 MiB.')
}
source = Buffer.alloc(stat.size)
let offset = 0
while (offset < source.length) {
const { bytesRead } = await file.read(source, offset, source.length - offset, null)
if (bytesRead === 0) throw new Error('Source changed during publishing.')
offset += bytesRead
}
} finally {
await file.close()
}
const options = { limitInputPixels: 12_000_000, failOn: 'warning' }
const metadata = await sharp(source, options).metadata()
if (!['jpeg', 'png'].includes(metadata.format) || (metadata.pages ?? 1) !== 1) {
throw new Error('Use a single-frame JPEG or PNG.')
}
const orientedWidth = metadata.autoOrient?.width ?? metadata.width
if (!orientedWidth || orientedWidth < 1280) throw new Error('Source must be at least 1280px wide.')
await mkdir('images', { recursive: true })
for (const width of [320, 640, 1280]) {
const result = await sharp(source, options).rotate().resize({ width }).webp({ quality: 80 })
.toBuffer({ resolveWithObject: true })
await writeFile('images/photo-' + width + '-v1.webp', result.data, { flag: 'wx' })
}
const fallback = await sharp(source, options).rotate().resize({ width: 640 })
.flatten({ background: 'white' }).jpeg({ quality: 80 }).toBuffer({ resolveWithObject: true })
await writeFile('images/photo-640-v1.jpg', fallback.data, { flag: 'wx' })
console.log(JSON.stringify({ width: fallback.info.width, height: fallback.info.height }))
}
main().catch(() => {
console.error('Image publishing failed. Review the input and output directory.')
process.exitCode = 1
})
Do not modify the input during a run. Exclusive writes prevent overwriting an existing version. A failed run may leave partial files: inspect them before retrying. Use a new version in both filenames and HTML for later publications, or add content-hashed filenames in your build pipeline. Sharp strips source metadata by default; check your desired orientation and color behavior.
ImageMagick
ImageMagick 7 offers a command-line alternative for a trusted source:
magick photo.jpg -auto-orient -resize '640x640>' -strip -quality 80 photo-small.webp
This fits within a square without enlargement, so its geometry differs from the width-only Sharp
variants. Check the output dimensions before using it in srcset. For untrusted files, isolate
the process and configure decoder/resource policies.
Cloudinary
Hosted transformation services define their own URL grammar and allowed source locations. Follow Cloudinary's transformation reference or the equivalent documentation for your provider. A URL from one provider cannot be used as a generic resize contract on another CDN.
Best practices for optimizing image delivery via CDN
Use long cache lifetimes only for immutable, versioned objects. Set correct content types and reserve the image's layout dimensions. Keep your responsive candidates consistent with actual output dimensions. Do not upload private originals into the public publication prefix.
Upload the generated files from the images/ directory to an approved public-content prefix in your
private-origin
bucket. Set their cache-control metadata intentionally, then inspect the CDN response headers.
Changing origin metadata does not instantly replace already cached responses.
Security considerations when using CDNs
Private origins and private delivery are separate controls. For private viewer access, configure CloudFront signed URLs or cookies and the associated trusted key group. An S3 presigned URL does not automatically sign CloudFront.
Keep signing keys server-side, authorize the requested object before issuing access and avoid logging signed query strings. Do not publish private responses with a shared public cache policy.
Troubleshoot common CDN image hosting challenges
Cache invalidation
Prefer a new immutable filename for a new image. When invalidation is necessary, target only the intended distribution and paths. An invalidation is a real account operation and may have a cost; do not run it as a generic debugging command against production.
Image load error handling
Remove the failed source elements inside picture before using a fallback, or the browser may
continue selecting
the broken candidate. Attempt the fallback only once:
function installImageFallback(image, fallbackUrl) {
image.addEventListener('error', () => {
for (const source of image.closest('picture')?.querySelectorAll('source') ?? []) {
source.remove()
}
image.removeAttribute('srcset')
image.removeAttribute('sizes')
image.src = fallbackUrl
}, { once: true })
}
const hero = document.getElementById('hero')
if (hero instanceof HTMLImageElement) installImageFallback(hero, '/images/placeholder.png')
Provide a real placeholder at that path and register the handler before loading images when failure can happen immediately. If the placeholder fails too, the browser retains the accessible alt text; the handler does not loop.
Performance monitoring
Only install a Largest Contentful Paint observer when the browser supports it:
if (typeof PerformanceObserver !== 'undefined' &&
PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')) {
const observer = new PerformanceObserver((list) => {
const latest = list.getEntries().at(-1)
if (latest) console.log('Observed LCP candidate in milliseconds:', latest.startTime)
})
observer.observe({ type: 'largest-contentful-paint', buffered: true })
window.addEventListener('pagehide', () => observer.disconnect(), { once: true })
}
This is a local diagnostic, not a complete Web Vitals reporting implementation. It intentionally does not log image URLs, which may contain access tokens. Compare representative devices, network conditions and cache states before drawing performance conclusions.
