Optimasi Performance Node.js Aplikasi dari Awal

by Marcus Chen
Optimasi Performance Node.js Aplikasi dari Awal

Ketika Response Time Tiba-tiba 3 Detik

Kamu deploy aplikasi Node.js, semua oke di local. Tapi begitu traffic naik sedikit — entah itu 50 concurrent users atau cuma beberapa endpoint yang hit database — response time melonjak ke 2–3 detik. Log penuh EventEmitter memory leak atau CPU usage nempel di 90%.

Ini bukan masalah server kurang kencang. Ini masalah kode.

Optimasi performance Node.js aplikasi bukan soal upgrade VPS atau pindah ke cloud yang lebih mahal. Sebagian besar bottleneck bisa diselesaikan di level kode sebelum kamu keluar uang lebih.

Artikel ini bahas masalah nyata yang sering gue temuin, lengkap dengan kode yang bisa langsung kamu pakai.


1. Profiling Dulu, Optimasi Kemudian

Kesalahan paling umum: langsung optimasi tanpa tahu bottleneck-nya di mana. Kamu buang waktu optimasi fungsi yang cuma dipanggil sekali, sementara query database yang dipanggil 500 kali per request dibiarkan.

Pakai clinic.js atau built-in --inspect dari Node.js untuk profiling.

# Install clinic
npm install -g clinic

# Jalankan aplikasi dengan doctor
clinic doctor -- node server.js

# Lalu hit endpoint kamu dengan autocannon
npx autocannon -c 100 -d 10 http://localhost:3000/api/users

Cara lain yang lebih simpel, pakai perf_hooks bawaan Node.js:

const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
  });
});
obs.observe({ entryTypes: ['measure'] });

// Di dalam route handler kamu
async function getUserData(userId) {
  performance.mark('db-start');
  const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
  performance.mark('db-end');
  performance.measure('database-query', 'db-start', 'db-end');
  return user;
}

Jalankan ini dulu. Lihat angkanya. Baru optimasi.


2. Event Loop Blocking — Musuh Utama Node.js

Node.js single-threaded. Kalau kamu jalankan operasi synchronous yang berat di main thread, semua request lain nunggu. Ini yang bikin aplikasi kamu terasa lambat padahal server masih sanggup.

Contoh kasus nyata: parsing JSON besar atau kompresi gambar di dalam request handler.

// JANGAN lakukan ini di request handler
app.get('/report', (req, res) => {
  // Ini blocking! Semua request lain nunggu
  const data = fs.readFileSync('./big-report.json', 'utf8');
  const parsed = JSON.parse(data); // kalau filenya 10MB, ini nyumbat event loop
  res.json(parsed);
});

// Lakukan ini
app.get('/report', async (req, res) => {
  try {
    const data = await fs.promises.readFile('./big-report.json', 'utf8');
    const parsed = JSON.parse(data);
    res.json(parsed);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

Untuk operasi CPU-intensive yang beneran berat (enkripsi, image processing, kompresi), pindahkan ke Worker Thread:

// worker.js
const { workerData, parentPort } = require('worker_threads');
const zlib = require('zlib');

const compressed = zlib.gzipSync(Buffer.from(workerData.content));
parentPort.postMessage({ compressed: compressed.toString('base64') });
// main.js
const { Worker } = require('worker_threads');

function compressInWorker(content) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', {
      workerData: { content }
    });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

app.post('/compress', async (req, res) => {
  const result = await compressInWorker(req.body.content);
  res.json(result);
});

Gotcha: jangan spawn Worker baru untuk setiap request. Bikin worker pool pakai library piscina kalau traffic kamu tinggi.


3. Database Query — Sumber Bottleneck Terbesar

Dalam pengalaman gue, 70% masalah performance Node.js bukan di Node.js-nya sendiri — tapi di cara aplikasi berinteraksi dengan database.

N+1 Query Problem adalah yang paling sering bikin sakit kepala:

// BURUK: N+1 queries
app.get('/posts', async (req, res) => {
  const posts = await db.query('SELECT * FROM posts');
  
  // Ini eksekusi 1 query untuk setiap post!
  for (const post of posts) {
    post.author = await db.query(
      'SELECT * FROM users WHERE id = ?', 
      [post.user_id]
    );
  }
  
  res.json(posts);
});

// BAGUS: Single query dengan JOIN
app.get('/posts', async (req, res) => {
  const posts = await db.query(`
    SELECT 
      posts.id,
      posts.title,
      posts.content,
      users.name AS author_name,
      users.email AS author_email
    FROM posts
    LEFT JOIN users ON posts.user_id = users.id
    WHERE posts.published = 1
    ORDER BY posts.created_at DESC
    LIMIT 20
  `);
  
  res.json(posts);
});

Selain itu, pastikan kamu pakai connection pooling. Jangan buka koneksi database baru untuk setiap request:

// config/database.js
const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,     // sesuaikan dengan kapasitas server
  queueLimit: 0,
  acquireTimeout: 60000,
  timeout: 60000,
});

module.exports = pool;

// Penggunaan di route
const db = require('./config/database');

app.get('/users', async (req, res) => {
  const [rows] = await db.execute('SELECT id, name, email FROM users');
  res.json(rows);
});

4. Caching yang Benar dengan Redis

Kalau ada data yang sering dibaca tapi jarang berubah — konfigurasi aplikasi, daftar kategori, data produk — jangan query database setiap kali. Cache.

// cache.js
const redis = require('ioredis');

const client = new redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
});

async function getOrSet(key, ttlSeconds, fetchFn) {
  try {
    const cached = await client.get(key);
    if (cached) {
      return JSON.parse(cached);
    }
    
    const fresh = await fetchFn();
    await client.setex(key, ttlSeconds, JSON.stringify(fresh));
    return fresh;
  } catch (err) {
    // Kalau Redis down, fallback ke database
    console.error('Cache error, falling back:', err.message);
    return fetchFn();
  }
}

module.exports = { getOrSet };
// Di route handler
const { getOrSet } = require('./cache');

app.get('/categories', async (req, res) => {
  const categories = await getOrSet(
    'categories:all',
    300, // cache 5 menit
    () => db.execute('SELECT * FROM categories WHERE active = 1')
  );
  
  res.json(categories);
});

Gotcha yang pernah gue kena: lupa handle error Redis. Kalau Redis down dan kamu nggak punya fallback, seluruh aplikasi ikut down padahal database masih oke. Selalu wrap Redis call dalam try-catch dengan fallback.


5. Compression dan Response Optimization

Kirim data lebih sedikit = response lebih cepat. Sesederhana itu.

const compression = require('compression');
const express = require('express');

const app = express();

// Aktifkan gzip compression
app.use(compression({
  level: 6,           // balance antara speed dan ratio
  threshold: 1024,    // compress kalau response > 1KB
  filter: (req, res) => {
    // Jangan compress kalau client minta no-transform
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));

// Set cache headers untuk static assets
app.use('/static', express.static('public', {
  maxAge: '1d',
  etag: true,
  lastModified: true,
}));

Untuk API response, jangan kirim field yang nggak dibutuhkan client. Kalau frontend cuma butuh id, name, email — jangan SELECT *.

// Buruk: kirim semua field termasuk password_hash, internal_notes, dll
const user = await db.execute('SELECT * FROM users WHERE id = ?', [id]);

// Bagus: kirim hanya yang diperlukan
const user = await db.execute(
  'SELECT id, name, email, avatar_url, created_at FROM users WHERE id = ?',
  [id]
);

6. Clustering untuk Manfaatkan Multi-Core

Node.js by default jalan di satu core. Kalau server kamu punya 4 core, kamu cuma pakai 25% kapasitasnya.

// cluster.js
const cluster = require('cluster');
const os = require('os');
const path = require('path');

if (cluster.isPrimary) {
  const numCPUs = os.cpus().length;
  console.log(`Primary ${process.pid} running, spawning ${numCPUs} workers`);
  
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
    cluster.fork(); // auto-restart worker yang mati
  });
  
} else {
  // Worker process jalankan Express app
  require('./server.js');
  console.log(`Worker ${process.pid} started`);
}

Jalankan dengan:

node cluster.js

Kalau kamu pakai PM2 (yang lebih disarankan untuk production), ini lebih simpel:

# Jalankan dengan jumlah worker = jumlah CPU
pm2 start server.js -i max --name "myapp"

# Monitor
pm2 monit

Gotcha clustering: session state. Kalau kamu simpan session di memory (default Express session), user bisa dapat worker berbeda di setiap request dan session-nya hilang. Selalu simpan session di Redis atau database kalau pakai clustering.


Yang Gue Lakuin Sekarang

Untuk optimasi performance Node.js aplikasi, urutan prioritas yang gue ikutin:

  1. Profile dulu — pakai clinic doctor + autocannon untuk identifikasi bottleneck nyata
  2. Fix N+1 queries — ini biasanya impact terbesar, paling cepat terasa
  3. Tambah connection pooling kalau belum ada
  4. Cache data statis dengan Redis, terutama endpoint yang hit database tapi datanya jarang berubah
  5. Aktifkan compression — satu baris kode, langsung terasa
  6. Clustering — langkah terakhir setelah kode sudah bersih

Jangan langsung lompat ke clustering atau upgrade server sebelum kamu fix masalah di level kode. Scaling aplikasi yang inefficient cuma bikin kamu bayar lebih mahal untuk masalah yang sama.

Coba profiling dulu di aplikasi kamu sekarang — kemungkinan besar kamu bakal nemuin N+1 query atau blocking operation yang selama ini luput dari perhatian.