karawaci.kode

2026-08-10 · 10 min

Image Optimization Pipeline Production: sharp, WebP, AVIF, dan Serve Sesuai Browser

Pipeline gambar yang naif itu sederhana: upload JPEG, simpan as-is, serve dari disk. Saya tahu karena itulah yang saya warisi dari klien Jakarta yang halaman produk-nya rata-rata 4MB hanya dari gambar. Sejak rebuild pipeline dengan sharp, WebP, AVIF, dan content negotiation yang benar, halaman yang sama turun ke bawah 900KB tanpa menyentuh satu baris kode frontend.

Masalah yang sebenarnya bukan soal “pakai format modern”

Banyak artikel berhenti di “gunakan WebP, hemat 30%.” Masalahnya tidak sesimple itu di production. Ada tiga layer yang harus diselesaikan bersamaan:

  1. Encoding pipeline — bagaimana gambar diproses dari upload ke output multi-format
  2. Content negotiation — bagaimana server memilih format yang dikirim ke tiap browser
  3. Caching — bagaimana hasil tidak di-encode ulang setiap request

Gagal di satu layer, dua layer lainnya tidak ada gunanya. Saya pernah lihat setup yang convert ke AVIF tapi serve format yang sama ke semua browser — akibatnya Safari iOS jadul menampilkan gambar rusak. Dan setup lain yang content negotiation-nya benar tapi tidak ada cache, sehingga CPU spike setiap ada request gambar ukuran baru.

Setup sharp untuk output multi-format

sharp adalah binding Node.js ke libvips, library C yang jauh lebih cepat dari ImageMagick atau Jimp untuk operasi batch. Install straightforward:

npm install sharp
# Pastikan versi libvips yang di-bundle support AVIF
# sharp >= 0.31 sudah bundle libvips dengan libaom

Ini fungsi inti yang saya pakai untuk konversi gambar:

// lib/image-pipeline.ts
import sharp from 'sharp';
import path from 'path';
import fs from 'fs/promises';

export interface TransformOptions {
  width?: number;
  height?: number;
  quality?: {
    webp?: number;
    avif?: number;
    jpeg?: number;
  };
}

export interface TransformResult {
  originalPath: string;
  webpPath: string;
  avifPath: string;
  sizes: { original: number; webp: number; avif: number };
}

export async function processImage(
  inputPath: string,
  outputDir: string,
  opts: TransformOptions = {},
): Promise<TransformResult> {
  const basename = path.basename(inputPath, path.extname(inputPath));
  const webpPath = path.join(outputDir, `${basename}.webp`);
  const avifPath = path.join(outputDir, `${basename}.avif`);

  const q = {
    webp: opts.quality?.webp ?? 82,
    avif: opts.quality?.avif ?? 60,
    jpeg: opts.quality?.jpeg ?? 85,
  };

  // Baca gambar sekali, proses ke beberapa format
  const pipeline = sharp(inputPath);

  if (opts.width || opts.height) {
    pipeline.resize(opts.width, opts.height, {
      fit: 'inside',
      withoutEnlargement: true,
    });
  }

  // Jalankan konversi paralel
  const [webpInfo, avifInfo] = await Promise.all([
    pipeline
      .clone()
      .webp({ quality: q.webp, effort: 4 })
      .toFile(webpPath),
    pipeline
      .clone()
      .avif({ quality: q.avif, effort: 4 }) // effort 4 dari max 9 — balance kecepatan vs ukuran
      .toFile(avifPath),
  ]);

  const originalStat = await fs.stat(inputPath);

  return {
    originalPath: inputPath,
    webpPath,
    avifPath,
    sizes: {
      original: originalStat.size,
      webp: webpInfo.size,
      avif: avifInfo.size,
    },
  };
}

Parameter effort pada AVIF dan WebP adalah knob yang sering diabaikan. Default sharp cukup agresif. Di production untuk on-demand processing, effort: 4 memberikan kompresi yang baik tanpa membuat request timeout. Untuk batch job saat upload, naikan ke effort: 6 untuk ukuran file lebih kecil.

Content negotiation di layer Express

Prinsipnya sederhana: browser mengirim Accept header yang menyatakan format yang didukung. Server harus membaca header ini dan mengembalikan format terbaik yang tersedia.

// routes/images.ts
import express from 'express';
import path from 'path';
import fs from 'fs/promises';
import { processImage } from '../lib/image-pipeline';
import { getFromCache, setCache } from '../lib/cache';

const router = express.Router();
const IMAGES_DIR = process.env.IMAGES_DIR ?? './uploads';

router.get('/:filename', async (req, res) => {
  const { filename } = req.params;
  const accept = req.headers.accept ?? '';

  // Tentukan format terbaik berdasarkan Accept header
  const wantsAvif = accept.includes('image/avif');
  const wantsWebp = accept.includes('image/webp');

  const basename = path.basename(filename, path.extname(filename));
  const originalPath = path.join(IMAGES_DIR, filename);

  // Cek original ada
  try {
    await fs.access(originalPath);
  } catch {
    return res.status(404).json({ error: 'Image not found' });
  }

  // Tentukan target format
  let targetExt: 'avif' | 'webp' | null = null;
  let contentType = 'image/jpeg';

  if (wantsAvif) {
    targetExt = 'avif';
    contentType = 'image/avif';
  } else if (wantsWebp) {
    targetExt = 'webp';
    contentType = 'image/webp';
  }

  if (!targetExt) {
    // Browser lama — serve original
    res.setHeader('Content-Type', contentType);
    res.setHeader('Vary', 'Accept');
    return res.sendFile(path.resolve(originalPath));
  }

  // Cek cache (path file hasil konversi)
  const cacheKey = `img:${basename}:${targetExt}`;
  const cachedPath = await getFromCache(cacheKey);

  if (cachedPath) {
    try {
      await fs.access(cachedPath);
      res.setHeader('Content-Type', contentType);
      res.setHeader('Vary', 'Accept');
      res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
      return res.sendFile(path.resolve(cachedPath));
    } catch {
      // File cache hilang, regenerate
    }
  }

  // Generate on-demand
  const outputDir = path.join(IMAGES_DIR, 'converted');
  await fs.mkdir(outputDir, { recursive: true });

  const result = await processImage(originalPath, outputDir);
  const outputPath = targetExt === 'avif' ? result.avifPath : result.webpPath;

  await setCache(cacheKey, outputPath, 86400 * 30); // cache 30 hari

  res.setHeader('Content-Type', contentType);
  res.setHeader('Vary', 'Accept');
  res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  return res.sendFile(path.resolve(outputPath));
});

export default router;

Header Vary: Accept adalah bagian yang paling sering terlewat. Tanpa header ini, CDN atau proxy di depan server akan men-cache satu format dan mengirimkannya ke semua browser, mengabaikan Accept header sama sekali. Vary: Accept memberi tahu cache bahwa respons bergantung pada header Accept — jadi Chrome yang minta AVIF dan Safari yang minta WebP akan mendapat entri cache yang berbeda.

Nginx sebagai lapisan cepat sebelum Node

Untuk gambar statis yang sudah di-pregenerate saat upload, saya bypass Node.js sama sekali dan biarkan Nginx yang melakukan content negotiation. Lebih cepat, lebih efisien:

# /etc/nginx/conf.d/images.conf

# Map Accept header ke ekstensi yang didukung
map $http_accept $img_ext {
  default         "";
  "~*image/avif"  ".avif";
  "~*image/webp"  ".webp";
}

server {
  listen 80;
  server_name images.example.com;

  root /var/www/uploads;

  location ~* \.(jpe?g|png|gif)$ {
    add_header Vary Accept;
    add_header Cache-Control "public, max-age=31536000, immutable";

    # Coba serve format modern dulu, fallback ke original
    try_files
      $uri$img_ext   # misal: foto.jpg.avif atau foto.jpg.webp
      $uri           # original
      =404;
  }
}

Konvensi naming foto.jpg.avif (tambah ekstensi baru di atas ekstensi lama) memudahkan try_files tanpa perlu regex kompleks. Saat upload, pipeline menyimpan tiga file: foto.jpg, foto.jpg.webp, dan foto.jpg.avif. Nginx memilih yang paling optimal secara otomatis.

Untuk setup yang lebih fleksibel dengan banyak ukuran (thumbnail, medium, large), saya pakai konvensi direktori:

/uploads/
  original/
    foto.jpg
  w400/
    foto.jpg
    foto.jpg.webp
    foto.jpg.avif
  w800/
    foto.jpg
    foto.jpg.webp
    foto.jpg.avif

Queue untuk konversi saat upload

Encoding AVIF berat di CPU. Melakukannya synchronous dalam request handler upload akan membuat upload timeout untuk gambar besar. Pisahkan ke background job:

// queue/image-processor.ts — contoh dengan BullMQ
import { Queue, Worker } from 'bullmq';
import { processImage } from '../lib/image-pipeline';

const imageQueue = new Queue('image-processing', {
  connection: { host: 'localhost', port: 6379 },
});

// Tambah job saat upload
export async function enqueueImageProcessing(
  originalPath: string,
  outputDir: string,
  sizes: Array<{ width: number; label: string }>,
) {
  await imageQueue.add(
    'process',
    { originalPath, outputDir, sizes },
    { attempts: 3, backoff: { type: 'exponential', delay: 2000 } },
  );
}

// Worker — jalankan di process terpisah
const worker = new Worker(
  'image-processing',
  async (job) => {
    const { originalPath, outputDir, sizes } = job.data;

    for (const size of sizes) {
      const sizeDir = path.join(outputDir, `w${size.width}`);
      await fs.mkdir(sizeDir, { recursive: true });
      await processImage(originalPath, sizeDir, { width: size.width });
    }
  },
  {
    connection: { host: 'localhost', port: 6379 },
    concurrency: 2, // Batasi concurrency — AVIF encoding CPU-intensive
  },
);

concurrency: 2 penting. Satu server dengan 4 core yang menjalankan 8 job AVIF paralel akan throttle semua request lain. Sesuaikan dengan jumlah core dan beban server Anda.

Trade-off yang harus diakui

AVIF encoding lambat. Bahkan dengan effort: 4, mengonversi gambar 4MP ke AVIF butuh 300-800ms di server modern. Untuk on-demand, ini berarti request pertama selalu lambat. Solusinya bukan menghindari AVIF, tapi memastikan enkoding terjadi di background dan hasil di-cache dengan benar sebelum gambar dipublikasikan.

Ukuran file tidak selalu AVIF < WebP < JPEG. Untuk gambar sangat kecil (icon 32x32, avatar 50x50), overhead AVIF header bisa membuat file-nya lebih besar dari JPEG. Untuk gambar dengan noise tinggi (foto grain, scanned document), gap antara AVIF dan WebP mengecil drastis. Tambahkan logic sederhana: setelah encode keduanya, serve yang ukurannya lebih kecil.

<picture> element di frontend. Kalau frontend Anda menggunakan <img> biasa, content negotiation di server sudah cukup. Tapi kalau pakai <picture> dengan <source> eksplisit, Anda bypass server-side negotiation sepenuhnya — browser langsung request URL format yang Anda tulis di srcset. Pilih satu pendekatan: server-side negotiation dengan satu URL, atau client-side dengan <picture> dan multiple URL. Jangan campur keduanya untuk gambar yang sama.

Verdict

Untuk aplikasi Node.js dengan jumlah gambar yang manageable (di bawah 100k file), setup sharp + BullMQ queue + Nginx content negotiation adalah sweet spot yang saya rekomendasikan. Tidak butuh CDN gambar dedicated (Cloudinary, imgix) yang biayanya bisa signifikan. Penghematan bandwidth nyata di angka 35-50% dibanding JPEG, dan dengan caching yang benar, overhead encoding AVIF hanya terjadi sekali per gambar.

Kalau traffic gambar Anda sudah sangat tinggi (jutaan request per hari) atau tim tidak mau maintain pipeline sendiri, saat itu baru pertimbangkan Cloudflare Images atau Bunny Optimizer — tapi itu bukan keputusan yang perlu diambil di awal. Sebagian besar aplikasi SaaS Indonesia yang saya tangani tidak butuh sampai ke sana.

Mulai dari satu endpoint: upload handler. Pastikan setiap gambar baru menghasilkan WebP dan AVIF saat upload, simpan ke direktori yang benar, dan biarkan Nginx serve format terbaik. Validasi dengan curl -H "Accept: image/avif" https://example.com/images/foto.jpg -v — kalau respons header menunjukkan Content-Type: image/avif dan ukuran file-nya lebih kecil, pipeline sudah bekerja.

Ditulis oleh Reza Pradipta