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.