Contentful

Contentful Asset Management and Image Optimization

A comprehensive guide to Contentful asset management covering Images API optimization, responsive srcset generation, bulk uploads, and image placeholder strategies.

Contentful Asset Management and Image Optimization

If you are serving images from Contentful and not using the Images API, you are shipping bloated pages. I have watched teams push 3MB hero images through Contentful without any transformation parameters, then wonder why their Lighthouse scores are in the gutter. Contentful gives you a full-featured image CDN backed by Fastly and an Images API that handles resizing, format conversion, and quality adjustment on the fly. You just have to use it.

This guide covers the entire Contentful asset pipeline from the ground up: the asset model, programmatic uploads, the Images API, responsive image generation, placeholder strategies, and a complete Node.js service you can drop into an Express application today.

The Contentful Asset Model

Every file you upload to Contentful becomes an asset. Assets are first-class citizens in the content model, sitting alongside entries but with their own lifecycle. An asset contains:

  • Title and description metadata
  • File information: filename, content type, size in bytes
  • Upload URL: the raw CDN URL after processing
  • Dimensions: width and height for images
  • Tags and metadata for organization

Assets support images (JPEG, PNG, GIF, WebP, AVIF, SVG), videos (MP4, WebM), documents (PDF, DOCX), and essentially any file type. The key distinction is that only raster images get access to the Images API transformations. SVGs are served as-is.

The CDN URL for any asset follows this pattern:

https://images.ctfassets.net/{space_id}/{asset_id}/{token}/{filename}

That base URL is your starting point. Everything else is query parameters.

Uploading Assets via the Management API

Before you can optimize images, you need to get them into Contentful. The Management API handles asset creation in three steps: create the asset, process it (which triggers CDN upload), and publish it.

var contentful = require('contentful-management');

var client = contentful.createClient({
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN
});

function uploadAsset(spaceId, environmentId, filePath, title, description) {
  var fs = require('fs');

  return client.getSpace(spaceId)
    .then(function(space) {
      return space.getEnvironment(environmentId);
    })
    .then(function(environment) {
      return environment.createAssetFromFiles({
        fields: {
          title: { 'en-US': title },
          description: { 'en-US': description || '' },
          file: {
            'en-US': {
              contentType: getMimeType(filePath),
              fileName: require('path').basename(filePath),
              file: fs.readFileSync(filePath)
            }
          }
        }
      });
    })
    .then(function(asset) {
      return asset.processForAllLocales();
    })
    .then(function(asset) {
      return pollUntilProcessed(asset);
    })
    .then(function(asset) {
      return asset.publish();
    });
}

function getMimeType(filePath) {
  var ext = require('path').extname(filePath).toLowerCase();
  var mimeTypes = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.gif': 'image/gif',
    '.webp': 'image/webp',
    '.svg': 'image/svg+xml',
    '.pdf': 'application/pdf',
    '.mp4': 'video/mp4'
  };
  return mimeTypes[ext] || 'application/octet-stream';
}

function pollUntilProcessed(asset, attempts) {
  attempts = attempts || 0;
  if (attempts > 30) {
    return Promise.reject(new Error('Asset processing timed out'));
  }

  return new Promise(function(resolve) {
    setTimeout(resolve, 1000);
  })
  .then(function() {
    return asset.getEnvironment();
  })
  .then(function(env) {
    return env.getAsset(asset.sys.id);
  })
  .then(function(freshAsset) {
    var file = freshAsset.fields.file['en-US'];
    if (file.url) {
      return freshAsset;
    }
    return pollUntilProcessed(freshAsset, attempts + 1);
  });
}

The processForAllLocales call is critical. Without it, your asset sits in an unprocessed state and has no CDN URL. Processing is asynchronous, which is why you need the polling function.

The Contentful Images API

The Images API is where the real value lives. Every image URL from Contentful accepts query parameters that trigger server-side transformations. These are processed at the CDN edge, cached, and served with proper cache headers. You are not paying for a separate image service. This is included with your Contentful plan.

Core Parameters

Parameter Description Example
w Width in pixels ?w=800
h Height in pixels ?h=600
fit Resize behavior ?fit=fill
f Focus area ?f=face
fm Output format ?fm=webp
q Quality (1-100) ?q=80
r Corner radius ?r=20
bg Background color ?bg=rgb:ffffff
fl Flags ?fl=progressive

Resize Fit Modes

The fit parameter controls how the image is resized when you specify both width and height:

  • pad: Resize to fit within dimensions, pad remaining space with background color
  • fill: Resize to fill dimensions, cropping as needed
  • scale: Stretch to exact dimensions (distorts aspect ratio)
  • crop: Crop to exact dimensions without resizing
  • thumb: Thumbnail with smart cropping (pairs well with f=face)
// Thumbnail with face detection
var thumbUrl = baseUrl + '?w=150&h=150&fit=thumb&f=face&fm=webp&q=80';

// Fill a hero banner area
var heroUrl = baseUrl + '?w=1920&h=600&fit=fill&fm=webp&q=85';

// Fit within bounds, preserve aspect ratio
var fitUrl = baseUrl + '?w=800&h=600&fit=pad&bg=rgb:f5f5f5';

Format Conversion

Contentful can convert between JPEG, PNG, WebP, AVIF, and GIF on the fly. This is the single biggest optimization you can make. WebP is typically 25-35% smaller than JPEG at equivalent quality, and AVIF pushes that to 40-50%.

var webpUrl = baseUrl + '?fm=webp&q=80';
var avifUrl = baseUrl + '?fm=avif&q=65';
var jpegProgressive = baseUrl + '?fm=jpg&fl=progressive&q=85';

A word on AVIF: the quality scale works differently. An AVIF at q=65 looks comparable to a JPEG at q=85. Start lower and work up.

Building a Responsive Image Service

Here is the core of what this article is about: a Node.js service that takes a Contentful image URL and generates everything you need for responsive, optimized images in your templates.

// services/imageOptimizer.js
var url = require('url');

var DEFAULT_WIDTHS = [320, 480, 768, 1024, 1280, 1920];
var DEFAULT_QUALITY = 80;
var PLACEHOLDER_WIDTH = 20;
var PLACEHOLDER_QUALITY = 30;

function ImageOptimizer(options) {
  this.widths = (options && options.widths) || DEFAULT_WIDTHS;
  this.quality = (options && options.quality) || DEFAULT_QUALITY;
  this.defaultFormat = (options && options.format) || 'webp';
}

ImageOptimizer.prototype.buildUrl = function(baseUrl, params) {
  var parsed = url.parse(baseUrl, true);
  // Strip existing query params to avoid conflicts
  var cleanUrl = parsed.protocol + '//' + parsed.host + parsed.pathname;
  var queryParts = [];

  Object.keys(params).forEach(function(key) {
    if (params[key] !== undefined && params[key] !== null) {
      queryParts.push(key + '=' + encodeURIComponent(params[key]));
    }
  });

  return cleanUrl + '?' + queryParts.join('&');
};

ImageOptimizer.prototype.srcset = function(imageUrl, options) {
  var self = this;
  var widths = (options && options.widths) || self.widths;
  var format = (options && options.format) || self.defaultFormat;
  var quality = (options && options.quality) || self.quality;
  var fit = (options && options.fit) || 'fill';

  return widths.map(function(w) {
    var optimizedUrl = self.buildUrl(imageUrl, {
      w: w,
      fm: format,
      q: quality,
      fit: fit,
      fl: format === 'jpg' ? 'progressive' : undefined
    });
    return optimizedUrl + ' ' + w + 'w';
  }).join(', ');
};

ImageOptimizer.prototype.placeholder = function(imageUrl) {
  return this.buildUrl(imageUrl, {
    w: PLACEHOLDER_WIDTH,
    fm: 'jpg',
    q: PLACEHOLDER_QUALITY,
    fl: 'progressive'
  });
};

ImageOptimizer.prototype.pictureData = function(imageUrl, options) {
  var self = this;
  var alt = (options && options.alt) || '';
  var sizes = (options && options.sizes) || '100vw';
  var widths = (options && options.widths) || self.widths;
  var quality = (options && options.quality) || self.quality;

  return {
    sources: [
      {
        type: 'image/avif',
        srcset: self.srcset(imageUrl, { format: 'avif', quality: Math.round(quality * 0.75), widths: widths }),
        sizes: sizes
      },
      {
        type: 'image/webp',
        srcset: self.srcset(imageUrl, { format: 'webp', quality: quality, widths: widths }),
        sizes: sizes
      }
    ],
    img: {
      src: self.buildUrl(imageUrl, { w: widths[widths.length - 1], fm: 'jpg', q: quality, fl: 'progressive' }),
      srcset: self.srcset(imageUrl, { format: 'jpg', quality: quality, widths: widths }),
      sizes: sizes,
      alt: alt,
      loading: 'lazy',
      decoding: 'async'
    },
    placeholder: self.placeholder(imageUrl)
  };
};

module.exports = ImageOptimizer;

Integrating with Express and Pug

Register the optimizer as a template local so every view has access:

// app.js
var ImageOptimizer = require('./services/imageOptimizer');
var optimizer = new ImageOptimizer({ quality: 82 });

app.use(function(req, res, next) {
  res.locals.imageOptimizer = optimizer;
  next();
});

Then in your Pug template:

//- views/mixins/responsive-image.pug
mixin responsiveImage(imageUrl, alt, sizes)
  - var data = imageOptimizer.pictureData(imageUrl, { alt: alt, sizes: sizes || '100vw' })
  picture
    each source in data.sources
      source(type=source.type, srcset=source.srcset, sizes=source.sizes)
    img(
      src=data.img.src,
      srcset=data.img.srcset,
      sizes=data.img.sizes,
      alt=data.img.alt,
      loading=data.img.loading,
      decoding=data.img.decoding,
      style="background-image: url(" + data.placeholder + "); background-size: cover;"
    )

Usage in any article or page template:

include mixins/responsive-image

+responsiveImage(article.heroImage, article.title, '(max-width: 768px) 100vw, 800px')

This generates a full <picture> element with AVIF and WebP sources, a JPEG fallback, lazy loading, async decoding, and a blur-up placeholder background. That is the entire responsive image story in one mixin call.

Image Placeholder Strategies

There are two dominant placeholder strategies for perceived performance: blur-up (LQIP) and solid color.

Blur-Up / LQIP

Low Quality Image Placeholders work by loading a tiny version of the image (15-30px wide), displaying it with CSS blur, then swapping in the full image when it loads. Contentful makes this trivial because you can generate the tiny version with query parameters.

ImageOptimizer.prototype.blurUpData = function(imageUrl) {
  var tinyUrl = this.buildUrl(imageUrl, {
    w: 20,
    fm: 'jpg',
    q: 25
  });

  // For inline base64 embedding (eliminates an extra request)
  var https = require('https');
  return new Promise(function(resolve, reject) {
    https.get(tinyUrl, function(response) {
      var chunks = [];
      response.on('data', function(chunk) { chunks.push(chunk); });
      response.on('end', function() {
        var buffer = Buffer.concat(chunks);
        var base64 = 'data:image/jpeg;base64,' + buffer.toString('base64');
        resolve(base64);
      });
      response.on('error', reject);
    });
  });
};

The base64-encoded placeholder is typically 300-600 bytes. Inline it directly in the HTML to avoid an extra network request:

// In your route handler
optimizer.blurUpData(article.heroImageUrl)
  .then(function(placeholder) {
    res.render('article', {
      article: article,
      heroPlaceholder: placeholder
    });
  });
.blur-up {
  filter: blur(10px);
  transition: filter 0.3s ease-out;
}
.blur-up.loaded {
  filter: blur(0);
}

Dominant Color Placeholder

If you want even less overhead, extract the dominant color from the LQIP and use a solid background:

ImageOptimizer.prototype.colorPlaceholder = function(imageUrl) {
  // Use a 1x1 pixel to get approximate dominant color
  return this.buildUrl(imageUrl, {
    w: 1,
    h: 1,
    fit: 'fill',
    fm: 'png'
  });
};

Bulk Asset Upload Script

When migrating content to Contentful, you need a reliable bulk uploader with rate limiting and error recovery. Contentful's Management API has a rate limit of roughly 7-10 requests per second. Exceed it and you get 429 responses.

// scripts/bulk-upload.js
var contentful = require('contentful-management');
var fs = require('fs');
var path = require('path');

var SPACE_ID = process.env.CONTENTFUL_SPACE_ID;
var ENV_ID = process.env.CONTENTFUL_ENV_ID || 'master';
var MGMT_TOKEN = process.env.CONTENTFUL_MANAGEMENT_TOKEN;
var RATE_LIMIT_DELAY = 200; // ms between requests

var client = contentful.createClient({
  accessToken: MGMT_TOKEN
});

function delay(ms) {
  return new Promise(function(resolve) {
    setTimeout(resolve, ms);
  });
}

function bulkUpload(directory, options) {
  var tagId = (options && options.tagId) || null;
  var extensions = (options && options.extensions) || ['.jpg', '.jpeg', '.png', '.gif', '.webp'];

  var files = fs.readdirSync(directory).filter(function(file) {
    return extensions.indexOf(path.extname(file).toLowerCase()) !== -1;
  });

  console.log('Found ' + files.length + ' files to upload');

  var environment;
  var results = { success: [], failed: [] };

  return client.getSpace(SPACE_ID)
    .then(function(space) {
      return space.getEnvironment(ENV_ID);
    })
    .then(function(env) {
      environment = env;
      return processFiles(environment, directory, files, tagId, results);
    })
    .then(function() {
      console.log('\nUpload complete:');
      console.log('  Success: ' + results.success.length);
      console.log('  Failed: ' + results.failed.length);

      if (results.failed.length > 0) {
        fs.writeFileSync(
          'failed-uploads.json',
          JSON.stringify(results.failed, null, 2)
        );
        console.log('  Failed files written to failed-uploads.json');
      }

      return results;
    });
}

function processFiles(environment, directory, files, tagId, results) {
  var index = 0;

  function next() {
    if (index >= files.length) {
      return Promise.resolve();
    }

    var file = files[index];
    index++;

    console.log('[' + index + '/' + files.length + '] Uploading: ' + file);

    var filePath = path.join(directory, file);
    var title = path.basename(file, path.extname(file))
      .replace(/[-_]/g, ' ')
      .replace(/\b\w/g, function(c) { return c.toUpperCase(); });

    var assetData = {
      fields: {
        title: { 'en-US': title },
        file: {
          'en-US': {
            contentType: getMimeType(filePath),
            fileName: file,
            file: fs.readFileSync(filePath)
          }
        }
      }
    };

    if (tagId) {
      assetData.metadata = {
        tags: [{ sys: { type: 'Link', linkType: 'Tag', id: tagId } }]
      };
    }

    return environment.createAssetFromFiles(assetData)
      .then(function(asset) {
        return asset.processForAllLocales();
      })
      .then(function(asset) {
        return pollUntilProcessed(asset);
      })
      .then(function(asset) {
        return asset.publish();
      })
      .then(function(asset) {
        results.success.push({ file: file, assetId: asset.sys.id });
      })
      .catch(function(err) {
        console.error('  FAILED: ' + err.message);
        results.failed.push({ file: file, error: err.message });
      })
      .then(function() {
        return delay(RATE_LIMIT_DELAY);
      })
      .then(next);
  }

  return next();
}

// Run from command line
var targetDir = process.argv[2];
var tag = process.argv[3] || null;

if (!targetDir) {
  console.error('Usage: node bulk-upload.js <directory> [tag-id]');
  process.exit(1);
}

bulkUpload(targetDir, { tagId: tag })
  .catch(function(err) {
    console.error('Fatal error:', err);
    process.exit(1);
  });

Run it with:

node scripts/bulk-upload.js ./images/blog-posts blog-images

Asset Organization: Tags, Metadata, and Linking

Contentful does not have traditional folders. Instead, you organize assets with tags (available on all plan tiers since 2023). Tags are reusable labels you create once and attach to any asset or entry.

function tagAssets(environment, assetIds, tagId) {
  return Promise.all(assetIds.map(function(assetId) {
    return environment.getAsset(assetId)
      .then(function(asset) {
        if (!asset.metadata) {
          asset.metadata = { tags: [] };
        }
        var alreadyTagged = asset.metadata.tags.some(function(tag) {
          return tag.sys.id === tagId;
        });
        if (alreadyTagged) return asset;

        asset.metadata.tags.push({
          sys: { type: 'Link', linkType: 'Tag', id: tagId }
        });
        return asset.update();
      });
  }));
}

For linking assets to entries, you create a reference field in your content type and set it via the Management API or the web app. In the Delivery API response, asset references resolve to the full asset object including the CDN URL.

CDN Caching Behavior

Contentful serves images through Fastly's CDN. Key caching details:

  • Cache TTL: Images are cached at the edge for up to 48 hours
  • Query parameter caching: Each unique combination of query parameters is a separate cache entry. ?w=800&q=80 and ?w=800&q=81 are two different cached objects
  • Cache invalidation: Publishing or unpublishing an asset purges all cached variants
  • Headers: Responses include Cache-Control: max-age=86400 and proper ETag headers

This means your first request with new parameters will hit Contentful's origin, but subsequent requests for the same URL are served from the nearest Fastly edge node. Do not add cache-busting query parameters to Contentful image URLs. You will defeat the CDN cache and degrade performance.

Asset Limits and Quotas

Know your limits before you hit them:

Limit Value
Maximum file size 20 MB (free), 200 MB (paid)
Image dimensions Up to 4000x4000 for transformations
Assets per space Varies by plan (25K on Community)
API rate limit ~7-10 req/sec (Management API)
Images API No explicit rate limit (CDN-served)

Images larger than 4000px on either dimension will not be transformed by the Images API. The original file is served instead. Always validate dimensions before upload if you depend on server-side transformations.

Monitoring Asset Usage

Track which assets are actually referenced and which are orphaned:

function findOrphanedAssets(environment) {
  var allAssets = [];
  var referencedAssetIds = new Set();

  return environment.getAssets({ limit: 1000 })
    .then(function(response) {
      allAssets = response.items;
      return environment.getEntries({ limit: 1000 });
    })
    .then(function(response) {
      var entries = response.items;
      entries.forEach(function(entry) {
        var fieldsJson = JSON.stringify(entry.fields);
        allAssets.forEach(function(asset) {
          if (fieldsJson.indexOf(asset.sys.id) !== -1) {
            referencedAssetIds.add(asset.sys.id);
          }
        });
      });

      return allAssets.filter(function(asset) {
        return !referencedAssetIds.has(asset.sys.id);
      });
    })
    .then(function(orphans) {
      console.log('Total assets: ' + allAssets.length);
      console.log('Referenced: ' + referencedAssetIds.size);
      console.log('Orphaned: ' + orphans.length);
      return orphans;
    });
}

Run this periodically. Orphaned assets count against your quota and clutter your media library. Delete them or archive them.

Common Issues and Troubleshooting

1. Images returning original format despite fm parameter

This happens when the source image is an SVG or GIF. SVGs are vector and cannot be converted. Animated GIFs can only be converted to other formats if you accept losing the animation. Check the contentType field of the asset before applying format parameters.

2. Asset processing stuck in "processing" state

The processForAllLocales call is async. If you try to publish before processing completes, you get an error. Always poll the asset status with retries and a timeout. In rare cases, processing genuinely fails (corrupted file, unsupported format). After 60 seconds of polling, treat it as a failure and log it.

3. Rate limiting (429 responses) during bulk operations

Contentful's Management API rate limit is shared across all API keys in a space. If you have CI/CD pipelines, webhooks, and a bulk upload running simultaneously, you will hit limits fast. Solutions: implement exponential backoff, stagger operations with delays, or use a queue. The RATE_LIMIT_DELAY constant in the bulk upload script above is your first line of defense.

4. Images API returning 400 for valid-looking parameters

Common causes: requesting a width or height of 0, using fit=thumb without specifying both w and h, applying f=face to an image with no detectable faces (this does not error, but it does nothing), or requesting dimensions larger than 4000px. Always validate parameters before constructing URLs.

5. Stale images after asset update

After you replace an asset's file and republish, the CDN cache may serve the old version for up to 48 hours. Contentful purges the cache on publish, but propagation across all Fastly nodes takes time. If you need immediate cache busting, append a version query parameter like &v=2 (but know this creates a new cache entry rather than invalidating the old one).

Best Practices

  1. Always specify both fm and q. Never serve images without format and quality parameters. The defaults are the original format and quality 100, which is almost always larger than necessary. Use fm=webp&q=80 as your baseline.

  2. Use the <picture> element with multiple sources. Serve AVIF to browsers that support it, WebP as a fallback, and JPEG as the final fallback. The pictureData method in the optimizer service above does exactly this.

  3. Generate srcset with sensible breakpoints. Do not generate 20 sizes. Five to seven widths covering 320px to 1920px handles the vast majority of devices. More sizes means more cache entries at the CDN and diminishing returns.

  4. Inline LQIP placeholders as base64 data URIs. At 20px wide and quality 25, a JPEG placeholder is under 500 bytes. Inlining it avoids an extra HTTP request and gives you instant visual feedback while the real image loads.

  5. Tag assets systematically from day one. Retroactively organizing hundreds of assets is painful. Establish a tagging convention (by content type, section, campaign) and enforce it in your upload scripts.

  6. Validate image dimensions on upload. If your images need to be transformed by the Images API, reject uploads larger than 4000x4000 at the application level. Do not wait for a silent fallback to the untransformed original.

  7. Monitor orphaned assets quarterly. Run the orphan detection script and clean up unused assets. They consume quota and create confusion in the media library.

  8. Use progressive JPEGs for fallback images. Add fl=progressive when fm=jpg. Progressive JPEGs render a low-quality full image first, then sharpen. This provides a built-in placeholder effect without any JavaScript.

References

Powered by Contentful