Mengelola pekerjaan background: pola async di thread terpisah, worker untuk CPU-bound task, queue untuk task management, dan cara melaporkan progres ke UI tanpa membekukan aplikasi CatatanKu

Di episode 2 kita sepakat: pekerjaan berat tidak boleh memblokir thread utama. Sekarang kita bangun implementasinya. Aplikasi CatatanKu yang realistis punya banyak pekerjaan yang berjalan "di belakang layar": mengindeks ribuan catatan untuk pencarian, menyinkronkan ke cloud, membuat thumbnail, atau mengekspor data. Semua ini harus berjalan bersamaan dengan UI yang tetap mulus.
Episode ini membedah tiga tingkat solusi: async untuk I/O-bound task, worker/isolate untuk CPU-bound task, dan task queue untuk mengelola banyak pekerjaan. Plus cara melaporkan progres kembali ke UI — pola yang akan dipakai di episode 13 (auto-update) dan 21 (AI lokal).
Task seperti membaca file, query database, atau request jaringan menghabiskan waktu menunggu (I/O), bukan menghitung. Solusi async (promise/async-await di JS, async/await di Rust dan Dart) membiarkan thread menunggu sambil tetap memproses hal lain:
const { ipcMain } = require("electron");
const fs = require("node:fs/promises");
ipcMain.handle("notes:export", async (_event, path) => {
const notes = await db.all("SELECT * FROM notes");
await fs.writeFile(path, JSON.stringify(notes, null, 2));
return { ok: true, count: notes.length };
});Selama await menunggu disk, main process tetap melayani event lain — window tidak membeku. Async menangani mayoritas tugas aplikasi desktop.
Task seperti indexing teks, hashing, atau memproses gambar adalah CPU-bound — ia menghitung terus tanpa menunggu apa pun. Async tidak membantu di sini: block-nya nyata. Solusinya: jalankan di thread/worker terpisah.
// main.ts
import { Worker } from "node:worker_threads";
import path from "node:path";
export function indexAllNotes(notes: Note[]) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, "index-worker.js"));
worker.postMessage(notes);
worker.on("message", (result) => resolve(result));
worker.on("error", reject);
});
}const { parentPort, workerData } = require("node:worker_threads");
function indexNotes(notes) {
const map = new Map();
for (const note of notes) {
for (const word of note.content.toLowerCase().split(/\W+/)) {
map.set(word, (map.get(word) ?? 0) + 1);
}
}
return [...map.entries()].sort((a, b) => b[1] - a[1]);
}
parentPort.on("message", (notes) => {
const result = indexNotes(notes);
parentPort.postMessage(result);
});Worker berjalan paralel dengan thread utama — CPU jajan sendiri, UI tetap halus. Di Tauri, pola yang sama dicapai dengan std::thread/tokio::spawn di Rust; di Flutter dengan Isolate.run.
final stats = await Isolate.run(() => computeNoteStats(notes));Saat pekerjaan background bertambah (indexing + sync + export berjalan bersamaan), kita butuh task queue: daftar pekerjaan dengan prioritas, eksekusi berurutan, dan kemungkinan dibatalkan.
type TaskKind = "index" | "sync" | "export" | "ai";
interface Task {
id: string;
kind: TaskKind;
priority: number; // lebih kecil = lebih dulu
payload: unknown;
cancel?: boolean;
}export class TaskQueue {
private tasks: Task[] = [];
private running = false;
enqueue(task: Task) {
this.tasks.push(task);
this.tasks.sort((a, b) => a.priority - b.priority);
this.run();
}
private async run() {
if (this.running) return;
this.running = true;
while (this.tasks.length > 0) {
const task = this.tasks.shift()!;
if (task.cancel) continue;
await dispatch(task); // kirim ke worker/async handler
}
this.running = false;
}
}Aturan praktis prioritas: operasi yang dianggap pengguna (simpan, ekspor) lebih tinggi dari operasi latar (index, sync). Di Tauri, tauri-plugin-task dan ekosistem tokio memberi pola ini; di Electron bisa memakai library seperti p-queue atau menulis antrean sendiri.
UI harus tahu progres task tanpa polling. Pola standar: backend emit event, frontend listen:
use tauri::{AppHandle, Emitter};
#[tauri::command]
async fn start_indexing(app: AppHandle) -> Result<(), String> {
let total = count_notes().await?;
for i in 0..total {
index_note(i).await?;
let _ = app.emit("index:progress", (i + 1, total));
}
let _ = app.emit("index:done", total);
Ok(())
}import { listen } from "@tauri-apps/api/event";
await listen<[number, number]>("index:progress", ({ payload }) => {
const [done, total] = payload;
setProgress(Math.round((done / total) * 100));
});function runIndexing() {
const total = notes.length;
for (let i = 0; i < total; i++) {
indexNote(i);
if (i % 10 === 0) {
mainWindow.webContents.send("index:progress", i + 1, total);
}
}
}Prinsip penting: jangan kirim event untuk setiap item pada dataset besar — batch (misal setiap 10 item, atau throttle per 100 ms) agar UI tidak kewalahan. Progres yang update 60 kali per detik tidak berguna jika pengguna hanya bisa membaca 4 per detik.
Tip
Aturan throttle untuk event progres: kirim maksimal ~4-10 event per detik, atau setiap batch N item. UI tidak memerlukan pembaruan tiap baris — ia hanya perlu tahu "pekerjaan masih berjalan" dan perkiraan persentase. Ini menjaga IPC tetap ringan dan renderer tetap mulus.
Rangkuman desain background CatatanKu:
async/await di backend process.index:progress, sync:progress, export:done.Inti yang harus dibawa pulang:
Di episode 10 selanjutnya kita akan mempercantik CatatanKu dengan UI & theming desktop — Material/desktop design, theming gelap-terang, dan responsivitas lintas resolusi. Sampai jumpa di episode 10!