karawaci.kode

2026-07-02 · 7 min

SQLite vs Postgres SaaS 1k MAU: 6 Bulan Real Data

Enam bulan lalu, saya migrate side project SaaS invoice — 1.200 MAU, ~95 RPS peak — dari Postgres ke SQLite + Litestream. Sebagian dari saya skeptis (“SQLite cuma untuk embedded”). Sebagian dari saya butuh hemat. Setelah 6 bulan, share angka real.

Sebelumnya saya sudah catat sedikit di SQLite Litestream small SaaS — tulisan ini lebih dalam, dengan angka Postgres comparison.

Konteks SaaS

  • App: SaaS invoice freelancer Indonesia (saya pribadi, paying customer subscription Rp 49k-249k/bulan).
  • MAU: 1.247 (bulan ke-6, growth ~8% MoM).
  • DAU: ~340 average, ~580 peak (akhir bulan: tagihan).
  • Data size: 4.8GB (50k invoice, 12k klien, 380k line item).
  • Write rate: peak 18 write/detik (akhir bulan), avg 4 write/detik.
  • Read rate: peak 95 read/detik.

Saya bukan startup VC-funded — saya freelance yang bangun SaaS sambilan. Cost optimization matter.

Setup awal: Postgres

  • Hetzner CCX23 (4 vCPU dedicated, 16GB RAM, 240GB NVMe) — €27/bulan ≈ Rp 480k.
  • PgBouncer transaction mode, 50 connection pool.
  • Daily logical backup ke S3-compatible (Backblaze B2).

Performance baseline (4 bulan Postgres production):

  • P50 query: 1.8ms
  • P95 query: 8.2ms
  • P99 query: 32ms (cold cache atau index miss)
  • Connection wait time p95: 4ms
  • Backup time daily: 6 menit

Cost komponen:

  • VPS: Rp 480k/bulan
  • B2 backup storage (60 hari rotation): Rp 22k/bulan
  • Total: Rp 502k/bulan

Migrasi ke SQLite + Litestream

Hipotesis: 95 RPS bisa di-handle SQLite di VPS yang jauh lebih kecil.

Setup baru:

  • Hetzner CX22 (2 vCPU shared, 4GB RAM, 40GB disk) — €5.4/bulan ≈ Rp 95k.
  • SQLite 3.46 dengan WAL mode, synchronous=NORMAL, cache_size=-64000 (64MB cache).
  • Litestream replicate ke Backblaze B2 (continuous, 10 detik interval).
  • App: Bun + Hono + better-sqlite3.

SQLite pragma:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 268435456;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;

Litestream config:

dbs:
  - path: /var/lib/kami/invoice.db
    replicas:
      - type: s3
        bucket: kami-litestream
        path: invoice
        endpoint: s3.us-west-002.backblazeb2.com
        access-key-id: ${B2_KEY_ID}
        secret-access-key: ${B2_APP_KEY}
        retention: 720h  # 30 hari
        snapshot-interval: 24h
        sync-interval: 10s

Migrasi data: dump dari Postgres, transform schema (jsonb → json, ENUM → TEXT CHECK), import ke SQLite. Total: 1 jam 40 menit untuk 4.8GB.

Performance setelah 6 bulan

Saya pakai metric yang sama, identical workload (Production traffic).

MetricPostgresSQLiteDelta
P50 query1.8ms0.12ms-93%
P95 query8.2ms0.8ms-90%
P99 query32ms4.1ms-87%
Write p95 (insert + commit)6.4ms1.2ms-81%
Concurrent write throughput max~800/s~180/s-78%
Connection wait p954ms0ms (no pool)-
Memory usage2.4GB280MB-88%
Backup latency (point-in-time)24h10shuge

SQLite menang besar di latency karena in-process — no network roundtrip, no connection pool overhead. Postgres menang di concurrent write (yang saya tidak butuh).

Memory turun 88% karena Postgres punya shared_buffers, work_mem, autovacuum worker, dll. SQLite cuma cache page + mmap.

Cost 6 bulan

ItemPostgresSQLite
VPS6 × Rp 480k = Rp 2.88 juta6 × Rp 95k = Rp 570k
Backup storage6 × Rp 22k = Rp 132k6 × Rp 18k = Rp 108k
Total 6 bulanRp 3.01 jutaRp 678k

Saving: Rp 2.33 juta / 6 bulan. Untuk solo SaaS dengan revenue Rp 12 juta/bulan: signifikan.

Disaster recovery test

Saya test 2 kali dalam 6 bulan:

Test 1: Simulated VPS loss

Saya rm -rf /var/lib/kami/invoice.db. Restore via Litestream:

litestream restore -if-replica-exists -o /var/lib/kami/invoice.db \
  s3://kami-litestream/invoice

Restore time untuk 4.8GB: 4 menit 12 detik. Data loss: 0 (Litestream sync interval 10s, last sync 3 detik sebelum delete).

Test 2: Corruption recovery

Saya corrupt file SQLite manual (dd if=/dev/urandom of=invoice.db count=1 bs=1024 seek=100). App crash. Restore via Litestream.

Restore time: 4 menit 8 detik. Data loss: 8 invoice yang di-write dalam 10 detik sebelum corruption (Litestream belum sync).

RPO 10 detik, RTO 5 menit. Untuk SLA 99.9% bulanan (43 menit downtime budget), acceptable.

Yang break

1. Concurrent write contention

Saat fitur “bulk import” diaktifkan (CSV upload, ~500 invoice per file), beberapa user trigger bersamaan. SQLite serialize write — user kedua wait ~12 detik kalau import pertama besar.

Fix: queue write besar ke background worker (BullMQ-like di Bun), respond ke user “import in progress, akan email saat selesai”.

Postgres bisa handle ini natively dengan multiple connection. Trade-off saya: accept queue UX untuk save Rp 380k/bulan.

2. Full-text search adoption

Postgres FTS dengan tsvector powerful. SQLite FTS5 OK tapi lebih basic.

Saat user request “search invoice by line item description”, saya butuh FTS. SQLite FTS5 setup:

CREATE VIRTUAL TABLE invoice_fts USING fts5(
  invoice_id UNINDEXED,
  description,
  content='line_items',
  content_rowid='id'
);

CREATE TRIGGER line_items_ai AFTER INSERT ON line_items BEGIN
  INSERT INTO invoice_fts(rowid, invoice_id, description) 
  VALUES (new.id, new.invoice_id, new.description);
END;

Works untuk Bahasa Indonesia search query simple. Tidak ada stemming bahasa Indonesia built-in (Postgres ada via unaccent + custom dictionary). Saya hidup tanpa stemming — user feedback ngga complain.

3. Backup verify yang tidak pernah saya jalankan

Saya backup tapi tidak pernah verify integritas backup sampai test 1 dan 2 di atas. Itu malpraktek. Sekarang saya jalankan automated restore test mingguan ke staging:

#!/bin/bash
# weekly-restore-test.sh
set -e

litestream restore -o /tmp/test-restore.db s3://kami-litestream/invoice
sqlite3 /tmp/test-restore.db "PRAGMA integrity_check;" | grep -q "ok" || exit 1
sqlite3 /tmp/test-restore.db "SELECT COUNT(*) FROM invoice;" | tee /tmp/count.txt
rm /tmp/test-restore.db

# Notify Slack
curl -X POST $SLACK_WEBHOOK -d "{\"text\":\"Litestream restore OK, $(cat /tmp/count.txt) invoice\"}"

4. Analytics query yang lambat

Akhir bulan, saya butuh report “total revenue per tier subscriber dengan growth MoM, breakdown by region”. Query Postgres dengan window function: 240ms. SQLite same query: 1.8 detik.

Untuk report internal, OK. Kalau saya butuh dashboard live dengan analytics berat, saya akan butuh datawarehouse terpisah (DuckDB? ClickHouse?). Sekarang saya jalankan report sekali sehari via cron, cache hasil. Tidak masalah.

Kapan saya tetap pilih Postgres

  • Multi-region active-active (SQLite tidak bisa)
  • Concurrent write > 200/detik (SQLite topi ~180/detik di VPS modest)
  • Complex window function / analytical query (Postgres planner lebih mature)
  • Team familiar Postgres dan tidak punya bandwidth belajar Litestream
  • Compliance yang butuh row-level security (Postgres native, SQLite manual)
  • Heavy JSON workload (Postgres jsonb path operator vs SQLite json_extract)

Verdict

Untuk solo / micro SaaS Indonesia dengan < 5k MAU dan write < 50/detik: SQLite + Litestream cocok, dan menghemat 75-85% cost database tier vs Postgres VPS.

Untuk SaaS 10k+ MAU atau multi-region: Postgres still default.

Yang penting bukan “SQLite vs Postgres” tapi “apa profil traffic Anda dan apa budget Anda”. Saya tidak akan migrate balik ke Postgres sampai MAU saya nyentuh 5-10k atau saya butuh feature yang SQLite tidak punya.

Bukan magic. SQLite di production butuh disiplin (backup, integrity check, monitoring). Tapi untuk profile kerjaan saya: cocok. Lihat juga Cloudflare D1 vs Postgres warung untuk konteks SQLite di edge.

Ditulis oleh Reza Pradipta