development

Optimasi Astro: Background Caching dan CDN Gambar Lokal

Asep Alazhari

Cara gue mempercepat loading gambar 40x lebih cepat dan menghemat bandwidth 95% dengan background caching dan optimasi gambar lokal di Astro.

Optimasi Astro: Background Caching dan CDN Gambar Lokal

Tantangannya: Bikin Landing Page Berita yang Ngebut dengan Budget Mepet

Bayangin deh: Lo lagi bikin landing page berita, ambil berita terbaru dari API eksternal, tampilkan gambar dari CDN. Kedengarannya gampang banget kan?

Ternyata nggak segampang itu ferguso.

Gue langsung ketemu masalah performa yang bikin developer manapun pusing tujuh keliling:

  • Gambar dari CDN eksternal loading lemot parah (50-200ms per gambar)
  • Pemanggilan API membanjiri news provider sampe sering kena rate limit
  • Resource constraints yang super ketat (512MB RAM, 0.5 CPU core per container aja)
  • Reliabilitas CDN yang nggak bisa diprediksi, kadang gambar sama sekali nggak loading

User experience-nya parah banget deh. Halaman terasa lambat. Gambar muncul dengan janggal. Dan yang paling bikin deg-degan, gue terus-menerus khawatir kena API rate limits atau CDN tiba-tiba down.

Gue butuh solusi yang bisa:

  1. Ngurangin beban ke API berita eksternal
  2. Bikin loading gambar jadi jauh lebih cepet
  3. Tetep dalam batas resource constraints yang ada
  4. Jalan dengan reliabel di production

Yang terjadi selanjutnya adalah perjalanan tiga bulan penuh iterasi, pembelajaran, dan optimasi. Inilah gimana gue berubah dari halaman berita yang lambat dan nggak bisa diandalkan jadi sistem yang kilat dan mandiri, ngirit bandwidth sampe 95% dan bikin gambar loading 40x lebih cepat.

Percobaan Pertama: In-Memory Caching

Pendekatan pertama gue keliatan cukup masuk akal nih: implementasi cache sederhana di memory dengan durasi 30 menit aja.

Gue lagi migrasi dari Next.js ke Astro, jadi gue pikir sekalian aja tambahin caching sederhana. Hasilnya langsung keliatan menjanjikan banget:

// Simple in-memory cache (percobaan pertama)
let newsCache = {
    data: [],
    timestamp: 0,
};

const CACHE_DURATION = 30 * 60 * 1000; // 30 menit

export async function GET() {
    const now = Date.now();

    // Cek apakah cache masih valid
    if (newsCache.data.length > 0 && now - newsCache.timestamp < CACHE_DURATION) {
        return new Response(JSON.stringify(newsCache), {
            headers: { "Content-Type": "application/json" },
        });
    }

    // Fetch data baru
    const freshData = await fetchNewsFromAPI();
    newsCache = { data: freshData, timestamp: now };

    return new Response(JSON.stringify(newsCache), {
        headers: { "Content-Type": "application/json" },
    });
}

Kabar Baiknya:

  • Berhasil ngurangin resource sampe 80% dengan konsolidasi dari 5 container jadi cuma 1 doang
  • Pemanggilan API turun drastis (dari ratusan per jam jadi cuma 2 kali aja)
  • Penggunaan memory tetep rendah (sekitar 100-150MB)

Masalah yang Gue Temukan:

Suatu hari, gue restart container buat update. Eh cache langsung ilang. User jadi ngeliat loading time lambat lagi sampe cache rebuild lagi deh.

Pembelajaran Pertama: Solusi in-memory emang bagus sih buat performa, tapi ternyata buruk banget buat persistence. Sistem production harus bisa survive restart dengan baik lah ya.

Iterasi Kedua: File-Based Persistent Cache

Tiga hari kemudian, gue dapet ide cemerlang nih. Kenapa nggak simpen aja cache-nya ke disk?

Gue bikin utility newsCache.js yang bakal:

  • Nyimpen data berita yang di-cache ke file JSON
  • Baca dari file pas startup
  • Sediain endpoint regenerasi manual

Ini nih implementasi intinya:

import { promises as fs } from "fs";
import path from "path";

const CACHE_DIR = path.join(process.cwd(), "dist", "cache");
const CACHE_FILE = path.join(CACHE_DIR, "news-data.json");

// Pastikan direktori cache ada
async function ensureDirectories() {
    try {
        await fs.mkdir(CACHE_DIR, { recursive: true });
    } catch (error) {
        console.error("Error creating cache directory:", error);
    }
}

// Baca cache dari file
export async function readCache() {
    try {
        const data = await fs.readFile(CACHE_FILE, "utf-8");
        return JSON.parse(data);
    } catch (error) {
        // Return cache kosong jika file tidak ada
        return { data: [], timestamp: 0, updatedAt: null };
    }
}

// Simpan cache ke file
async function saveCache(newsItems) {
    await ensureDirectories();

    const cacheData = {
        data: newsItems,
        timestamp: Date.now(),
        updatedAt: new Date().toISOString(),
    };

    await fs.writeFile(CACHE_FILE, JSON.stringify(cacheData, null, 2));
    console.log(`[NewsCache] Saved ${newsItems.length} items to cache`);
}

Gue juga tambahin endpoint regenerasi manual:

// /api/regenerate - Refresh cache manual
export async function POST({ request }) {
    const apiKey = request.headers.get("X-API-Key");

    // Autentikasi API key sederhana
    if (apiKey !== process.env.REGEN_API_KEY) {
        return new Response(JSON.stringify({ error: "Unauthorized" }), {
            status: 401,
        });
    }

    const success = await regenerateCache();
    const cache = await readCache();

    return new Response(
        JSON.stringify({
            success: true,
            count: cache.data.length,
            timestamp: cache.timestamp,
        })
    );
}

Kenapa File-Based Cache?

  1. Persistence (bertahan saat container restart)
  2. Memory Efficient (nggak makan RAM pas nggak dipakai)
  3. Gampang Debug (tinggal cat news-data.json buat liat isi cache)
  4. Tanpa Dependency Eksternal (nggak perlu Redis, Memcached, atau database)

Masalah yang Masih Tersisa:

Gue masih butuh cron job eksternal buat hit endpoint /api/regenerate setiap 2 jam. Ini terasa kikuk banget deh. Gue harus setup service terpisah cuma buat jaga cache tetep fresh.

Pembelajaran Kedua: Persistence emang nyelesaiin masalah restart sih, tapi dependency eksternal malah bikin kompleksitas operasional baru deh.


Baca Juga:


Terobosan: Background Scheduler dan Optimasi Gambar

Dua bulan berlalu nih. Sistemnya jalan sih, tapi cron job eksternal itu terus-terusan ganggu pikiran gue. Pasti ada cara yang lebih baik.

Trus tiba-tiba gue dapet ide. Gimana kalo scheduler-nya hidup aja di dalam aplikasi itu sendiri?

Dan sekalian aja deh, kenapa nggak selesaiin juga masalah gambar CDN yang lambat itu?

Arsitekturnya

Gue design sistem dengan tiga komponen inti:

  1. Background Scheduler (node-cron): Jalan di dalam container, refresh cache setiap 2 jam
  2. Image Downloader: Download gambar CDN ke filesystem lokal pas cache refresh
  3. Orchestrator (start.js): Koordinasi antara Astro server dan scheduler

Begini cara semuanya bekerja bersama:

Docker Container
 └── start.js (Orchestrator)
      ├── Background Scheduler (node-cron)
      │    └── Berjalan setiap 2 jam
      │         ├── Fetch berita dari API
      │         ├── Download gambar ke filesystem lokal
      │         ├── Simpan file cache JSON
      │         └── Cleanup gambar lama

      └── Astro Server (mode SSR)
           └── endpoint /api/news serve dari file cache
                └── Cache-Control: public, max-age=3600

Bagian 1: Background Scheduler

Pertama, gue bikin scheduler pake node-cron:

// scheduler.js
import cron from "node-cron";
import { regenerateCache, cleanupOldImages } from "./utils/newsCache.js";

export function startScheduler() {
    // Berjalan setiap 2 jam: 0 */2 * * *
    const scheduleExpression = "0 */2 * * *";

    console.log("[Scheduler] Starting background scheduler...");
    console.log(`[Scheduler] Will run every 2 hours: ${scheduleExpression}`);

    const task = cron.schedule(
        scheduleExpression,
        async () => {
            console.log("[Scheduler] Running scheduled cache regeneration...");

            try {
                const success = await regenerateCache();

                if (success) {
                    console.log("[Scheduler] Cache regenerated successfully");

                    // Cleanup gambar lama yang tidak ada di cache lagi
                    await cleanupOldImages();
                    console.log("[Scheduler] Image cleanup completed");
                } else {
                    console.warn("[Scheduler] Cache regeneration failed");
                }
            } catch (error) {
                console.error("[Scheduler] Error during scheduled task:", error);
            }
        },
        {
            scheduled: true,
            timezone: "Asia/Jakarta",
        }
    );

    // Jalankan cache awal saat startup
    console.log("[Scheduler] Running initial cache regeneration...");
    regenerateCache().catch(console.error);

    return task;
}

Bagian 2: Optimasi Gambar Lokal

Di sinilah keajaibannya terjadi nih. Daripada serve gambar dari CDN eksternal, gue download sekali aja dan serve secara lokal:

// Download dan proses gambar
const IMAGES_DIR = path.join(process.cwd(), "dist", "client", "images", "news");

function getFilenameFromUrl(url) {
    try {
        const urlObj = new URL(url);
        const pathname = urlObj.pathname;
        const filename = pathname.split("/").pop();
        return filename || `image-${Date.now()}.jpg`;
    } catch (error) {
        return `image-${Date.now()}.jpg`;
    }
}

async function downloadImage(url, filename) {
    try {
        console.log(`[NewsCache] Downloading image: ${filename}`);

        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        const arrayBuffer = await response.arrayBuffer();
        const buffer = Buffer.from(arrayBuffer);

        const filePath = path.join(IMAGES_DIR, filename);
        await fs.writeFile(filePath, buffer);

        // Return path lokal
        return `/images/news/${filename}`;
    } catch (error) {
        console.error(`[NewsCache] Error downloading ${filename}:`, error.message);
        return null; // Akan gunakan URL original sebagai fallback
    }
}

async function processNewsItems(newsItems) {
    await fs.mkdir(IMAGES_DIR, { recursive: true });

    const processedItems = [];

    for (const item of newsItems) {
        const processedItem = { ...item };

        if (item.enclosure) {
            const filename = getFilenameFromUrl(item.enclosure);
            const localPath = await downloadImage(item.enclosure, filename);

            // Simpan URL original sebagai fallback
            processedItem.enclosureOriginal = item.enclosure;
            processedItem.enclosure = localPath || item.enclosure;
        }

        processedItems.push(processedItem);
    }

    return processedItems;
}

Kenapa Ini Bekerja Sangat Baik:

  1. Performa: Baca filesystem lokal 10-40x lebih cepet dari request CDN
  2. Reliabilitas: Nggak tergantung sama ketersediaan CDN eksternal
  3. Bandwidth: Gambar di-download sekali setiap 2 jam, bukan setiap page view
  4. Fallback: URL original disimpen di enclosureOriginal

Bagian 3: Cleanup Gambar Cerdas

Gue nggak mau gambar lama numpuk selamanya, jadi gue tambahin logika cleanup:

export async function cleanupOldImages() {
    try {
        // Baca cache saat ini untuk mendapat list gambar aktif
        const cache = await readCache();
        const currentImages = new Set();

        cache.data.forEach((item) => {
            if (item.enclosure?.startsWith("/images/news/")) {
                const filename = item.enclosure.split("/").pop();
                currentImages.add(filename);
            }
        });

        // Baca semua file di direktori gambar
        const files = await fs.readdir(IMAGES_DIR);

        // Hapus gambar yang tidak ada di cache saat ini
        let deletedCount = 0;
        for (const file of files) {
            if (!currentImages.has(file)) {
                await fs.unlink(path.join(IMAGES_DIR, file));
                deletedCount++;
            }
        }

        if (deletedCount > 0) {
            console.log(`[NewsCache] Cleaned up ${deletedCount} old images`);
        }
    } catch (error) {
        console.error("[NewsCache] Error during image cleanup:", error);
    }
}

Bagian 4: Orchestrator

Akhirnya, gue perlu jalanin Astro server dan scheduler secara bersamaan:

// start.js
import { spawn } from "child_process";
import { startScheduler } from "./scheduler.js";

console.log("[Start] Initializing application...");

// Start background scheduler
const schedulerTask = startScheduler();
console.log("[Start] Background scheduler started");

// Start Astro server
const astroServer = spawn("node", ["./dist/server/entry.mjs"], {
    stdio: "inherit",
    env: {
        ...process.env,
        HOST: "0.0.0.0",
        PORT: "4321",
    },
});

// Handle graceful shutdown
process.on("SIGTERM", () => {
    console.log("[Start] Received SIGTERM, shutting down gracefully...");
    schedulerTask.stop();
    astroServer.kill("SIGTERM");
});

process.on("SIGINT", () => {
    console.log("[Start] Received SIGINT, shutting down gracefully...");
    schedulerTask.stop();
    astroServer.kill("SIGINT");
});

astroServer.on("exit", (code) => {
    console.log(`[Start] Astro server exited with code ${code}`);
    process.exit(code);
});

Hasil Performa: Angka-Angka Tidak Berbohong

Setelah deploy solusi final ini, peningkatannya sangat dramatis:

Perbandingan Sebelum vs Sesudah

MetrikSebelum (CDN)Sesudah (Lokal)Peningkatan
Waktu Load Gambar50-200ms1-5ms10-40x lebih cepat
Waktu Respons API100-300ms1-10ms30x lebih cepat
Bandwidth NetworkTinggi (setiap request)Rendah (sekali per 2jam)Pengurangan 95% 📉
Dependency CDNKritisTidak adaEliminasi 100%
Storage CacheN/A~500KBDapat diabaikan 💾

Penggunaan Resource (Production)

Memory Usage (per container):
├── Base Astro Server: ~80-100 MB
├── Node.js Runtime: ~30-40 MB
├── Background Scheduler: ~5-10 MB (idle)
├── Cache Regeneration (peak): +20-30 MB
└── Image Downloads (peak): +10-15 MB (sementara)

Total: 115-150 MB idle / 145-195 MB peak
Container Limit: 512 MB (headroom 60%) ✅

CPU Usage:
├── Idle State: 0.1-0.3%
├── Serving Requests: 1-5% per request
├── Cache Regeneration: 5-15% (setiap 2 jam)
└── Image Downloads: 10-20% (spike singkat, 5-10s)

Container Limit: 0.5 CPU (50%) ✅

Penghematan Biaya:

  • Bandwidth: Pengurangan ~95% traffic outbound = biaya CDN/egress lebih rendah
  • Infrastructure: 80% lebih sedikit container = biaya hosting lebih rendah
  • Operational: Tidak perlu service cron eksternal = arsitektur lebih sederhana

Arsitektur Deployment: Membuat Production-Ready

Membuat ini bekerja di production membutuhkan konfigurasi Docker yang cerdas:

Multi-Stage Dockerfile

# Stage 1: Build
FROM node:20.18.1-alpine AS builder
WORKDIR /home/app

COPY package*.json ./
COPY yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build  # Build Astro + jalankan post-build.js

# Stage 2: Production
FROM node:20.18.1-alpine AS runner

# Install dependencies
RUN apk add --no-cache dumb-init curl

# Buat app user
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

WORKDIR /home/app

# Buat direktori writable dengan permission yang benar
RUN mkdir -p /home/app/dist/cache && \
    mkdir -p /home/app/dist/client/images/news && \
    chown -R appuser:appgroup /home/app

# Copy file hasil build dan scheduler
COPY --from=builder --chown=appuser:appgroup /home/app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /home/app/package*.json ./
COPY --from=builder --chown=appuser:appgroup /home/app/node_modules ./node_modules

USER appuser

EXPOSE 4321

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
    CMD node -e "require('http').get('http://localhost:4321/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

# Start orchestrator (Astro + Scheduler)
CMD ["dumb-init", "node", "./dist/start.js"]

Post-Build Script

Buat dapetin file scheduler ke dalam direktori dist, gue bikin post-build script:

// scripts/post-build.js
import { copyFile, mkdir } from "fs/promises";
import { join } from "path";

const srcDir = "src";
const distDir = "dist";

async function main() {
    console.log("[Post-Build] Copying scheduler files to dist...");

    // Buat direktori yang diperlukan
    await mkdir(join(distDir, "utils"), { recursive: true });
    await mkdir(join(distDir, "cache"), { recursive: true });
    await mkdir(join(distDir, "client", "images", "news"), { recursive: true });

    // Copy file scheduler
    await copyFile(join(srcDir, "start.js"), join(distDir, "start.js"));

    await copyFile(join(srcDir, "scheduler.js"), join(distDir, "scheduler.js"));

    await copyFile(join(srcDir, "utils", "newsCache.js"), join(distDir, "utils", "newsCache.js"));

    console.log("[Post-Build] Scheduler files copied successfully");
}

main().catch(console.error);

Tambahin ke package.json:

{
    "scripts": {
        "build": "astro build && node scripts/post-build.js"
    }
}

Baca Juga:


Pembelajaran Penting

Liat lagi perjalanan tiga bulan ini, ini nih insight yang bikin beda:

1. Evolusi Itu Normal

Jangan harapin solusi sempurna di percobaan pertama. Tiga iterasi gue ngajarin:

  • Percobaan pertama: Cache in-memory (quick win, tapi rapuh)
  • Iterasi kedua: Cache file-based (nyelesaiin persistence, tapi kikuk)
  • Solusi final: Solusi terintegrasi (elegan dan production-ready)

Setiap iterasi adalah batu loncatan, bukan kegagalan.

2. File-Based Caching Sering Diremehkan

Di dunia Redis dan Memcached, gampang lupa kalo filesystem itu cache yang sempurna buat banyak use case:

  • Tanpa dependency eksternal
  • Bertahan saat restart
  • Gampang debug
  • Memory efficient

Jangan over-kompleks kalo solusi sederhana udah jalan.

3. Gambar Lokal Lebih Baik dari CDN buat Performa yang Prediktabel

Ini ngagetin gue sih, tapi angka nggak bohong:

  • 10-40x lebih cepat loading time
  • Pengurangan bandwidth 95%
  • Nggak ada kekhawatiran reliabilitas CDN
  • Biaya storage minimal (sekitar 500KB)

Buat konten yang berubah jarang (setiap 2 jam), storage lokal adalah pilihan paling masuk akal.

4. Background Jobs Nggak Butuh Service Eksternal

Awalnya gue pikir butuh service cron terpisah. Ternyata, node-cron yang jalan di dalam aplikasi itu:

  • Lebih gampang di-deploy (satu container ketimbang dua service)
  • Lebih gampang monitor (log di satu tempat aja)
  • Lebih reliabel (nggak ada network calls antar service)

5. Selalu Sediain Fallback

Perhatiin deh gimana gue tetep simpen enclosureOriginal di cache? Itu disengaja lho. Kalo download gambar gagal, app langsung fallback ke CDN URL.

Defense in depth bukan cuma buat security aja, tapi juga buat reliability.

6. Ukur Semuanya

Tanpa metrik, gue nggak bakal tau:

  • Bahwa gambar makan waktu 50-200ms
  • Bahwa gue berhasil dapet peningkatan 10-40x
  • Bahwa penggunaan resource tetep dalam batas

Ukur, optimasi, ukur lagi deh.


Baca Juga:


Kapan Sebaiknya Pake Pola Ini?

Pendekatan ini paling cocok kalo:

  • Konten update jarang (per jam, setiap beberapa jam)
  • API eksternal punya rate limits atau masalah performa
  • Gambar CDN lambat atau nggak reliabel
  • Resource constraints butuh efisiensi
  • Kesederhanaan operasional penting (lebih sedikit moving parts)

Nggak ideal buat:

  • Real-time data (harga saham, skor live)
  • User-generated content yang butuh visibilitas langsung
  • Library gambar masif (local storage jadi mahal)

Kesimpulan: Dari Lambat Jadi Kilat

Yang dimulai sebagai masalah performa yang bikin frustasi berubah jadi masterclass dalam iterasi dan optimasi nih.

Dengan menggabungkan:

  • Background scheduling pake node-cron
  • File-based persistent caching
  • Optimasi gambar lokal
  • Strategi cleanup yang cerdas

Gue berhasil transform halaman berita yang lambat dan nggak bisa diandalkan jadi sistem yang kilat dan mandiri yang:

  • Loading gambar 40x lebih cepat
  • Ngurangin bandwidth sampe 95%
  • Motong biaya infrastructure 80%
  • Ngeliminasi dependency eksternal

Perjalanan ini ngajarin gue bahwa performa hebat nggak butuh infrastruktur yang ribet kok. Kadang solusi terbaik itu justru yang paling sederhana.

Back to Blog

Related Posts

View All Posts »