Efficient image delivery: creating your own CDN

Building your own Content Delivery Network (CDN) for images can significantly enhance your application's performance and give you full control over image delivery. In this guide, we explore how to build and optimize a custom CDN for efficient image delivery.
Benefits of using a CDN for images
A CDN for images offers several advantages:
- Reduced server load and bandwidth costs: Offloading image delivery to a CDN minimizes the strain on your primary servers.
- Faster image loading times: Serve images from locations closer to your users for reduced latency.
- Improved SEO performance: Faster page loads can boost your search engine rankings.
- Scalability: Easily handle traffic spikes without additional infrastructure.
- Custom optimization: Tailor image delivery for different devices and contexts.
Understanding CDN functionality
A CDN is a network of distributed servers that deliver content based on the geographic location of the user. By caching content at multiple edge locations, a CDN reduces latency and improves load times for users worldwide.
Components needed to create your own image CDN
To set up a custom image CDN, you'll need:
- A web server to handle incoming requests
- A storage solution for your images (local or cloud-based)
- Image processing tools for optimization
- A caching mechanism to speed up delivery
- Security measures to protect your assets
- Monitoring tools to track performance and load
Setting up a basic CDN infrastructure
Let's create a basic CDN using Node.js (>= 18.17.0) and Express. First, set up your dependencies:
{
"dependencies": {
"express": "^4.18.2",
"sharp": "^0.33.5",
"express-rate-limit": "^7.5.0",
"compression": "^1.7.4",
"helmet": "^7.1.0",
"redis": "^4.6.13"
}
}
Now, let's set up the server:
const express = require('express')
const compression = require('compression')
const path = require('path')
const sharp = require('sharp')
const fs = require('fs')
// Requires Node.js >= 18.17.0 for full Sharp compatibility
const app = express()
app.use(compression())
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp']
const cacheControl = (req, res, next) => {
res.set('Cache-Control', 'public, max-age=31536000')
next()
}
const validateImageRequest = (req, res, next) => {
const width = parseInt(req.query.width)
if (width && (width < 50 || width > 2000)) {
return res.status(400).json({ error: 'Width must be between 50 and 2000 pixels' })
}
const quality = parseInt(req.query.quality)
if (quality && (quality < 1 || quality > 100)) {
return res.status(400).json({ error: 'Quality must be between 1 and 100' })
}
next()
}
const optimizeImage = async (req, res, next) => {
try {
const imageName = req.params.image
const imageExtension = path.extname(imageName).toLowerCase()
if (!allowedExtensions.includes(imageExtension)) {
return res.status(400).json({ error: 'Unsupported image format' })
}
const imagePath = path.join(__dirname, 'images', imageName)
if (!fs.existsSync(imagePath)) {
return res.status(404).json({ error: 'Image not found' })
}
const width = parseInt(req.query.width)
const quality = parseInt(req.query.quality) || 80
const format = req.accepts(['avif', 'webp', 'jpeg']) || 'jpeg'
let transformer = sharp(imagePath)
if (!isNaN(width)) {
transformer = transformer.resize(width, null, {
withoutEnlargement: true,
})
}
switch (format) {
case 'avif':
transformer = transformer.avif({ quality: 60, effort: 6 })
res.type('image/avif')
break
case 'webp':
transformer = transformer.webp({ quality: 80 })
res.type('image/webp')
break
default:
transformer = transformer.jpeg({ quality: 85 })
res.type('image/jpeg')
}
const buffer = await transformer.toBuffer()
res.send(buffer)
} catch (error) {
next(error)
}
}
Integrating your custom CDN with cloud storage
For better scalability, integrate your CDN with cloud storage services like Google Cloud Storage or Amazon S3:
const { Storage } = require('@google-cloud/storage')
const storage = new Storage()
const fetchFromCloud = async (req, res, next) => {
try {
const bucketName = process.env.BUCKET_NAME
if (!bucketName) {
throw new Error('Bucket name not configured')
}
const fileName = req.params.image
const width = parseInt(req.query.width)
const quality = parseInt(req.query.quality) || 80
const format = req.accepts(['avif', 'webp', 'jpeg']) || 'jpeg'
const bucket = storage.bucket(bucketName)
const file = bucket.file(fileName)
const [exists] = await file.exists()
if (!exists) {
return res.status(404).json({ error: 'Image not found' })
}
let transformer = sharp()
if (!isNaN(width)) {
transformer = transformer.resize(width, null, {
withoutEnlargement: true,
})
}
switch (format) {
case 'avif':
transformer = transformer.avif({ quality: 60, effort: 6 })
res.type('image/avif')
break
case 'webp':
transformer = transformer.webp({ quality: 80 })
res.type('image/webp')
break
default:
transformer = transformer.jpeg({ quality: 85 })
res.type('image/jpeg')
}
file.createReadStream().pipe(transformer).pipe(res)
} catch (error) {
if (error.code === 404) {
return res.status(404).json({ error: 'Image not found' })
}
console.error('Cloud storage error:', error)
return res.status(500).json({ error: 'Internal server error' })
}
}
app.get('/cloud-images/:image', validateImageRequest, cacheControl, fetchFromCloud)
Leveraging cache for faster image delivery
Implement caching using Redis for improved performance:
const { createClient } = require('redis')
const redis = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
})
redis.on('error', (err) => console.error('Redis Client Error:', err))
const cacheMiddleware = async (req, res, next) => {
const cacheKey = `img:${req.originalUrl}`
try {
await redis.connect()
const cachedImage = await redis.get(cacheKey)
if (cachedImage) {
const imageBuffer = Buffer.from(cachedImage, 'base64')
res.type(req.accepts(['avif', 'webp', 'jpeg']) || 'jpeg')
return res.send(imageBuffer)
}
res.sendResponse = res.send
res.send = async (body) => {
await redis.set(cacheKey, body.toString('base64'), {
EX: 86400, // 24 hours
})
res.sendResponse(body)
}
next()
} catch (error) {
console.error('Cache error:', error)
next()
}
}
app.get('/images/:image', validateImageRequest, cacheControl, cacheMiddleware, optimizeImage)
Enhancing security: securing your image data
Implement security measures using Helmet and rate limiting:
const rateLimit = require('express-rate-limit')
const helmet = require('helmet')
app.use(helmet())
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'blob:'],
},
}),
)
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 1000,
standardHeaders: true,
legacyHeaders: false,
})
app.use('/images/', limiter)
Monitoring and scaling your custom CDN
Implement basic monitoring and error tracking:
const responseTime = require('response-time')
app.use(
responseTime((req, res, time) => {
console.log(`${req.method} ${req.url}: ${time.toFixed(2)}ms`)
}),
)
app.use((err, req, res, next) => {
console.error(`Error processing ${req.url}:`, err)
res.status(500).json({ error: 'Internal server error' })
})
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
})
Starting the CDN server
app.listen(3000, () => {
console.log('CDN server running on port 3000')
})
Testing the performance of your image CDN
Use autocannon for load testing:
const autocannon = require('autocannon')
const test = async () => {
const result = await autocannon({
url: 'http://localhost:3000/images/test.jpg',
connections: 100,
duration: 10,
headers: {
Accept: 'image/webp',
},
})
console.log(result)
}
test()
Conclusion
Building your own CDN for images provides flexibility and control over image delivery while optimizing performance. By implementing caching, compression, and monitoring, you can create a robust solution for serving images at scale.
If you prefer a managed solution for image processing and delivery, consider using Transloadit, which offers a reliable image CDN service out of the box.