Image Thumbnails Generation
Thumbnail generation transforms uploaded images into multiple sizes for different contexts (list, card, full-size view) without losing original.
Laravel: Intervention Image + queue
// Model with automatic thumbnail generation
class Image extends Model
{
const SIZES = [
'thumb' => [200, 200],
'medium' => [600, 400],
'large' => [1200, 800],
];
}
// Job for async generation
class GenerateImageThumbnails implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(private Image $image) {}
public function handle(): void
{
$originalPath = Storage::disk('s3')->path($this->image->path);
$img = \Intervention\Image\Facades\Image::make($originalPath);
foreach (Image::SIZES as $size => [$width, $height]) {
$resized = clone $img;
$resized->fit($width, $height); // crop to center
$thumbPath = str_replace('original/', "{$size}/", $this->image->path);
Storage::disk('s3')->put($thumbPath, $resized->encode('webp', 85)->__toString());
}
$this->image->update(['processed' => true]);
}
}
// Upload controller
public function store(Request $request): JsonResponse
{
$path = Storage::disk('s3')->putFile('original', $request->file('image'));
$image = Image::create([
'path' => $path,
'user_id' => auth()->id(),
'processed' => false,
]);
GenerateImageThumbnails::dispatch($image);
return response()->json(['id' => $image->id]);
}
Node.js: Sharp
Sharp is the fastest Node.js image processing library (based on libvips).
import sharp from 'sharp';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
const SIZES = {
thumb: { width: 200, height: 200 },
medium: { width: 600, height: 400 },
large: { width: 1200, height: 800 },
} as const;
async function generateThumbnails(s3Key: string): Promise<Record<string, string>> {
const s3 = new S3Client({ region: 'eu-west-1' });
// Download original
const { Body } = await s3.send(new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: s3Key,
}));
const buffer = Buffer.from(await (Body as any).transformToByteArray());
const results: Record<string, string> = {};
await Promise.all(
Object.entries(SIZES).map(async ([name, { width, height }]) => {
const thumbnail = await sharp(buffer)
.resize(width, height, { fit: 'cover', position: 'centre' })
.webp({ quality: 85 })
.toBuffer();
const thumbKey = s3Key.replace('original/', `${name}/`).replace(/\.[^.]+$/, '.webp');
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: thumbKey,
Body: thumbnail,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000',
}));
results[name] = thumbKey;
})
);
return results;
}
Lazy generation via Glide (PHP)
Glide generates thumbnails on demand with signed URLs:
// Image route
Route::get('/img/{path}', function (Request $request, string $path) {
$server = League\Glide\ServerFactory::create([
'source' => Storage::disk('s3')->getDriver(),
'cache' => Storage::disk('local')->getDriver(),
'cache_path_prefix' => '.cache',
'base_url' => '/img',
'max_image_size' => 2000 * 2000,
]);
// Validate signed URL
League\Glide\Signatures\SignatureFactory::create(config('app.key'))
->validateRequest('/img/' . $path, $request->all());
return $server->getImageResponse($path, $request->all());
})->where('path', '.*');
// Generate signed URL
$url = (new League\Glide\Urls\UrlBuilderFactory)
->create('/img', config('app.key'))
->getUrl('uploads/photo.jpg', ['w' => 400, 'h' => 300, 'fit' => 'crop']);
Formats and optimization
// Choose format by browser support
const output = sharp(buffer)
.resize(800)
.toFormat(supportsAvif ? 'avif' : supportsWebp ? 'webp' : 'jpeg', {
quality: supportsAvif ? 60 : supportsWebp ? 80 : 85,
});
AVIF gives 50% savings compared to JPEG at same quality. WebP is supported by all modern browsers. For maximum compatibility use <picture> with multiple formats.
Implementation timeline
Thumbnail generation in queue (Laravel Job or BullMQ Worker) with S3 storage: 2–3 days. With lazy generation via Glide and CDN caching: 3–4 days.







