Part of the MediaBridge series.
Why Thumbnails in Lambda
Thumbnail generation on the backend server would mean every file upload triggers a download from S3, a resize operation, and an upload back to S3 - all inline with the upload flow. That adds latency to every upload, burns bandwidth on the server, and blocks the upload response until the thumbnail is ready.
Lambda is a better fit. S3 fires an event for every object creation. The Lambda processes it asynchronously, after the upload has already completed and the user has their presigned URL. The backend is not involved.
A single Lambda covers all 33 S3 buckets. It has one job: respond to S3 object events and maintain the thumbnail layer.
Trigger
The Lambda is connected to ObjectCreated:* and ObjectRemoved:* events on every managed bucket. On every event, it:
- Skips thumbnail files (
t-prefix) to avoid infinite loops - Fires the cache eviction webhook on the backend
- For buckets in the
THUMBNAIL_BUCKETSset: generates or deletes the thumbnail
The skip-on-t- check runs first, before any other logic:
const fileName = key.substring(key.lastIndexOf('/') + 1);
if (fileName.startsWith('t-')) continue;
This prevents a thumbnail creation from triggering another Lambda invocation that tries to generate a thumbnail of the thumbnail.
Cache Eviction First
Every event, regardless of thumbnail generation, fires the cache eviction webhook:
async function evictCache(bucket, key) {
const payload = JSON.stringify({ bucket_name: bucket, key });
const url = new URL(`${EVICT_WEBHOOK_URL}/cache/evict`);
// POST with X-Evict-Secret header
}
This keeps the MediaBridge file browser consistent with actual bucket state even when files are uploaded outside MediaBridge - via rclone, the AWS CLI, or the S3 console. Any external change fires the Lambda, which evicts the stale cache entries, so the next browse reflects reality.
Image Thumbnails via Sharp
For image files (JPEG, PNG, GIF, WebP, HEIC, BMP), the Lambda downloads the source file, resizes it to 400x300 with center-crop, and saves as JPEG at 85% quality:
async function generateImageThumbnail(buffer, extension) {
try {
return await sharp(buffer)
.resize(400, 300, { fit: 'cover', position: 'center' })
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return generateFileTypeIcon(extension);
}
}
The catch branch is important. Sharp can fail on corrupt or unusual image files. Rather than failing the whole Lambda invocation, a failed image thumbnail falls back to generating the file type icon for that extension.
SVG Icons for Non-Image Files
For everything else - PDFs, Word documents, spreadsheets, video, audio, archives, code files - the Lambda generates a color-coded SVG icon. The color is determined by extension:
const CATEGORY_COLORS = {
pdf: '#c0392b',
doc: '#2980b9', docx: '#2980b9', odt: '#2980b9',
xls: '#27ae60', xlsx: '#27ae60', csv: '#27ae60',
mp4: '#6a1b9a', mkv: '#6a1b9a', avi: '#6a1b9a',
mp3: '#ad1457', wav: '#ad1457', aac: '#ad1457',
zip: '#4527a0', rar: '#4527a0', '7z': '#4527a0',
// ...
default: '#78909c',
};
The SVG is a 400x300 image with a solid colored background, a document shape in white, and the file extension in uppercase in a badge at the bottom:
function generateFileTypeIcon(extension) {
const bg = CATEGORY_COLORS[extension] || CATEGORY_COLORS.default;
const label = extension.toUpperCase().substring(0, 4);
const fontSize = label.length <= 3 ? 20 : 16;
const svg = `<svg width="400" height="300" ...>
<rect width="400" height="300" fill="${bg}"/>
<path d="M156 92 H214 L244 122 V204 ..." fill="white" opacity="0.93"/>
<rect x="166" y="176" width="68" height="28" rx="5" fill="${bg}"/>
<text x="200" y="199" font-size="${fontSize}" fill="white">${label}</text>
</svg>`;
return Buffer.from(svg);
}
A PDF gets a red icon with “PDF”. An MP4 gets a purple icon with “MP4”. An unknown extension gets a grey icon with up to 4 characters of the extension.
Already-Exists Check
Before downloading the source file and doing any work, the Lambda checks whether the thumbnail already exists in S3:
try {
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: thumbnailKey }));
return; // already exists
} catch (err) {
if (err.name !== 'NotFound' && err.$metadata?.httpStatusCode !== 404) throw err;
}
If the thumbnail exists, the function returns immediately. This handles re-uploads cleanly - if a file is overwritten with a new version, the cache eviction fires but the thumbnail is only regenerated if the old thumbnail was already evicted. For a different version of the same file, eviction on delete then re-creation on upload handles the refresh cycle.
Thumbnail Key Naming
Thumbnails are stored in the same prefix as the source file, with t- prepended to the filename:
function toThumbnailKey(key) {
const parts = key.split('/');
parts[parts.length - 1] = `t-${parts[parts.length - 1]}`;
return parts.join('/');
}
projects/marketing/campaign.pdf becomes projects/marketing/t-campaign.pdf. This keeps thumbnails co-located with their source files and makes the naming deterministic - the frontend constructs the thumbnail URL by applying the same transform without needing to look it up.
On Delete
When a source file is deleted, the Lambda deletes the corresponding thumbnail:
async function deleteThumbnail(bucket, key) {
try {
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: toThumbnailKey(key) }));
} catch (err) {
console.warn(`[thumbnail] delete failed: ${err.message}`);
}
}
Failures are logged and swallowed. A missing thumbnail after a delete is not a critical error.
Lazy Thumbnail Generation from Backend
Not every bucket participates in thumbnail generation - only those in THUMBNAIL_BUCKETS. For files uploaded before the Lambda was connected, or in buckets that were added to the system after the Lambda was already configured, thumbnails may not exist.
The backend has a lazy generation endpoint: POST /thumbnails/generate. When the browser tries to display a thumbnail and gets a 404 or error, it can call this endpoint. The backend downloads the file from S3, generates the thumbnail, uploads it, and the next load shows the image. This acts as a fallback for any gap in the Lambda coverage.
Next: Deploying MediaBridge