Kirim 50 task reCAPTCHA lewat Promise.all naif, dan yang terjadi bukan penyelesaian lebih cepat — melainkan ERROR_NO_SLOT_AVAILABLE bertubi-tubi karena semua request menabrak jatah thread CaptchaAI Anda dalam hitungan detik. Artikel ini membahas lima pola antrean Node.js, dari Promise.allSettled sederhana sampai sistem job production-grade dengan retry dan dead-letter.
Pilih pola sesuai skala task:
Promise.allSettled— batch kecil, uji coba awal.ConcurrencyQueue— task tembus ratusan, butuh kontrol thread.CaptchaQueue(EventEmitter) — perlu progress real-time.PriorityCaptchaQueue— task tidak sama penting, checkout vs scraping.RetryQueue— keandalan production, dead-letter untuk task gagal permanen.
Batch Sederhana dengan Promise.allSettled
Untuk task dalam jumlah kecil — belasan sampai beberapa puluh — pola paling mudah dipelihara adalah Promise.allSettled. Setiap CAPTCHA diselesaikan lewat solveSingle(), yang mengirim task ke in.php, lalu polling res.php setiap 5 detik sampai statusnya 1 (selesai) atau muncul ERROR_CAPTCHA_UNSOLVABLE. Karena allSettled menunggu semua promise beres — baik yang berhasil maupun gagal — Anda tidak kehilangan hasil task lain hanya karena satu CAPTCHA timeout.
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function solveSingle(method, params) {
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({ key: API_KEY, method, json: "1", ...params }),
});
const submitData = await submitResp.json();
if (submitData.status !== 1) throw new Error(submitData.request);
const taskId = submitData.request;
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const data = await pollResp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") throw new Error("Unsolvable");
}
throw new Error("Timed out");
}
// Solve all at once
async function solveBatch(tasks) {
const results = await Promise.allSettled(
tasks.map((task) => solveSingle(task.method, task.params))
);
return results.map((result, i) => ({
taskId: tasks[i].id,
status: result.status,
value: result.status === "fulfilled" ? result.value : null,
error: result.status === "rejected" ? result.reason.message : null,
}));
}
// Usage
const tasks = Array.from({ length: 10 }, (_, i) => ({
id: i,
method: "userrecaptcha",
params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
}));
const results = await solveBatch(tasks);
console.log(`Solved: ${results.filter((r) => r.status === "fulfilled").length}/10`);
Pola ini cukup untuk uji coba awal atau volume rendah. Masalah baru muncul saat jumlah task naik ke ratusan: seluruh request ditembakkan bersamaan, dan CaptchaAI membalas ERROR_NO_SLOT_AVAILABLE begitu jumlah thread aktif melebihi jatah paket Anda.
Antrean dengan Batas Concurrency
ConcurrencyQueue menyelesaikan masalah di atas dengan mengontrol berapa banyak task yang berjalan bersamaan lewat maxConcurrent. Task baru menunggu di this.queue sampai ada slot kosong; begitu satu solve selesai — berhasil maupun gagal — #process() otomatis menarik task berikutnya. Ini sekaligus jadi mekanisme backpressure alami: antrean tidak pernah membanjiri CaptchaAI dengan lebih banyak request daripada yang bisa ditangani jatah thread Anda.
class ConcurrencyQueue {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
this.results = [];
}
add(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.#process();
});
}
async #process() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) return;
this.running++;
const { fn, resolve, reject } = this.queue.shift();
try {
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.#process();
}
}
async addBatch(fns) {
return Promise.allSettled(fns.map((fn) => this.add(fn)));
}
}
// Usage
const queue = new ConcurrencyQueue(5);
const tasks = Array.from({ length: 20 }, (_, i) => () =>
solveSingle("userrecaptcha", {
googlekey: `KEY_${i}`,
pageurl: `https://example.com/${i}`,
})
);
const results = await queue.addBatch(tasks);
const solved = results.filter((r) => r.status === "fulfilled");
console.log(`Solved: ${solved.length}/${results.length}`);
Contoh nyata: agensi price-monitoring di Jakarta yang memantau ratusan halaman produk e-commerce tiap malam biasanya menyamakan maxConcurrent dengan jatah thread paket CaptchaAI mereka — 5 thread untuk paket BASIC ($15/bulan), naik ke 50 thread begitu pindah ke ADVANCE ($90/bulan). Karena setiap paket menyertakan solve tak terbatas per thread, menaikkan maxConcurrent sampai batas thread tidak menambah tagihan bulanan — yang berubah hanya throughput.
Antrean Berbasis EventEmitter untuk Progress Real-Time
Kalau Anda perlu menampilkan progress ke dashboard internal atau log terstruktur — bukan sekadar menunggu Promise selesai — EventEmitter bawaan Node.js pas dipakai. CaptchaQueue memancarkan event submitted, solved, failed, dan complete, masing-masing membawa stats terkini. Anda tinggal pasang .on() di listener mana pun tanpa mengubah logika solve-nya.
const { EventEmitter } = require("events");
class CaptchaQueue extends EventEmitter {
#apiKey;
#maxConcurrent;
#pending;
#active;
constructor(apiKey, maxConcurrent = 5) {
super();
this.#apiKey = apiKey;
this.#maxConcurrent = maxConcurrent;
this.#pending = [];
this.#active = 0;
this.stats = { submitted: 0, solved: 0, failed: 0 };
}
submit(id, method, params) {
this.#pending.push({ id, method, params });
this.stats.submitted++;
this.emit("submitted", { id, total: this.stats.submitted });
this.#drain();
}
async #drain() {
while (this.#active < this.#maxConcurrent && this.#pending.length > 0) {
const task = this.#pending.shift();
this.#active++;
this.#solve(task).finally(() => {
this.#active--;
this.#drain();
if (this.#active === 0 && this.#pending.length === 0) {
this.emit("complete", this.stats);
}
});
}
}
async #solve(task) {
try {
const token = await solveSingle(task.method, task.params);
this.stats.solved++;
this.emit("solved", { id: task.id, token, stats: { ...this.stats } });
} catch (error) {
this.stats.failed++;
this.emit("failed", { id: task.id, error: error.message, stats: { ...this.stats } });
}
}
}
// Usage
const queue = new CaptchaQueue("YOUR_API_KEY", 5);
queue.on("submitted", ({ id, total }) => {
console.log(`Submitted #${id} (total: ${total})`);
});
queue.on("solved", ({ id, stats }) => {
console.log(`Solved #${id} — ${stats.solved}/${stats.submitted}`);
});
queue.on("failed", ({ id, error }) => {
console.log(`Failed #${id}: ${error}`);
});
queue.on("complete", (stats) => {
const rate = ((stats.solved / stats.submitted) * 100).toFixed(1);
console.log(`Done: ${stats.solved}/${stats.submitted} (${rate}%)`);
});
// Submit tasks
for (let i = 0; i < 15; i++) {
queue.submit(i, "userrecaptcha", {
googlekey: `KEY_${i}`,
pageurl: `https://example.com/${i}`,
});
}
Pola ini enak dipisah ke worker terpisah: satu proses mengurus antrean dan solve, proses lain — atau dashboard — cukup mendengarkan event tanpa polling status secara manual.
Antrean Prioritas: Checkout Didahulukan, Scraping Menyusul
Tidak semua task CAPTCHA sama pentingnya. Task checkout yang menahan transaksi pelanggan jelas lebih mendesak dibanding job scraping katalog yang bisa selesai kapan saja. PriorityCaptchaQueue menyusun task lewat angka prioritas — makin kecil angkanya, makin dulu dieksekusi begitu ada slot maxConcurrent kosong.
class PriorityQueue {
#items = [];
enqueue(item, priority) {
this.#items.push({ item, priority });
this.#items.sort((a, b) => a.priority - b.priority);
}
dequeue() {
return this.#items.shift()?.item;
}
get length() {
return this.#items.length;
}
}
class PriorityCaptchaQueue {
#apiKey;
#maxConcurrent;
#queue;
#active;
#results;
constructor(apiKey, maxConcurrent = 5) {
this.#apiKey = apiKey;
this.#maxConcurrent = maxConcurrent;
this.#queue = new PriorityQueue();
this.#active = 0;
this.#results = new Map();
}
submit(id, method, params, priority = 5) {
return new Promise((resolve, reject) => {
this.#queue.enqueue({ id, method, params, resolve, reject }, priority);
this.#drain();
});
}
async #drain() {
while (this.#active < this.#maxConcurrent && this.#queue.length > 0) {
const task = this.#queue.dequeue();
this.#active++;
solveSingle(task.method, task.params)
.then((token) => {
this.#results.set(task.id, { status: "solved", token });
task.resolve(token);
})
.catch((err) => {
this.#results.set(task.id, { status: "error", error: err.message });
task.reject(err);
})
.finally(() => {
this.#active--;
this.#drain();
});
}
}
}
// Usage: high-priority checkout, low-priority scraping
const pq = new PriorityCaptchaQueue("YOUR_API_KEY", 3);
// Priority 1 (highest) — checkout
const checkoutToken = pq.submit(
"checkout_1",
"turnstile",
{ sitekey: "KEY", pageurl: "https://shop.com/checkout" },
1
);
// Priority 5 (normal) — product scraping
for (let i = 0; i < 5; i++) {
pq.submit(
`product_${i}`,
"userrecaptcha",
{ googlekey: "KEY", pageurl: `https://shop.com/p/${i}` },
5
);
}
Pada contoh di atas, task checkout_1 bertipe turnstile mendapat prioritas 1 (tertinggi), sementara lima task scraping produk berjalan di prioritas 5 (normal). Kalau kedua jenis task berebut thread yang sama, checkout selalu menang antre.
Retry dan Dead-Letter Supaya Task Gagal Tidak Menumpuk
Task CAPTCHA gagal tidak selalu berarti ada yang salah dengan kode Anda — sitekey berubah, halaman lambat merender, atau koneksi sempat putus. RetryQueue mencoba ulang task yang gagal sampai maxRetries, lalu memindahkan task yang tetap gagal ke #deadLetter supaya tidak menyumbat antrean utama selamanya.
class RetryQueue {
#apiKey;
#maxRetries;
#results;
#deadLetter;
constructor(apiKey, maxRetries = 3) {
this.#apiKey = apiKey;
this.#maxRetries = maxRetries;
this.#results = [];
this.#deadLetter = [];
}
async processBatch(tasks, maxConcurrent = 5) {
const queue = tasks.map((t) => ({ ...t, attempts: 0 }));
while (queue.length > 0) {
const batch = queue.splice(0, maxConcurrent);
const results = await Promise.allSettled(
batch.map((task) => this.#solveWithRetry(task))
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
const task = batch[i];
if (result.status === "fulfilled") {
this.#results.push({ id: task.id, token: result.value });
} else {
task.attempts++;
if (task.attempts < this.#maxRetries) {
queue.push(task); // Retry
console.log(`Retry ${task.attempts}/${this.#maxRetries}: ${task.id}`);
} else {
this.#deadLetter.push({
id: task.id,
error: result.reason.message,
attempts: task.attempts,
});
}
}
}
}
return {
solved: this.#results,
failed: this.#deadLetter,
};
}
async #solveWithRetry(task) {
return solveSingle(task.method, task.params);
}
}
Kabar baiknya: karena semua paket CaptchaAI menagih per thread aktif per bulan, bukan per percobaan solve, retry yang wajar tidak menambah biaya bulanan Anda. Yang perlu dijaga hanyalah
maxRetries— supaya task yang memang tidak bisa diselesaikan tidak berputar tanpa henti dan menahan thread yang seharusnya dipakai task lain.
Dashboard Pemantauan Antrean
QueueMonitor merekam angka dasar yang biasa ditanyakan tim ops: berapa task yang sedang berjalan, rata-rata waktu penyelesaian, throughput per menit, dan tingkat keberhasilan. Panggil recordSubmit(), recordSolved(), recordFailed() di titik yang sesuai, lalu report() kapan saja untuk snapshot kondisi antrean.
class QueueMonitor {
#startTime;
#solveTimes;
constructor() {
this.#startTime = Date.now();
this.#solveTimes = [];
this.counts = { submitted: 0, solving: 0, solved: 0, failed: 0 };
}
recordSubmit() {
this.counts.submitted++;
this.counts.solving++;
}
recordSolved(solveTime) {
this.counts.solving--;
this.counts.solved++;
this.#solveTimes.push(solveTime);
}
recordFailed() {
this.counts.solving--;
this.counts.failed++;
}
report() {
const elapsed = (Date.now() - this.#startTime) / 1000;
const avgTime =
this.#solveTimes.length > 0
? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length
: 0;
const throughput = this.counts.solved / (elapsed / 60);
const successRate =
this.counts.solved + this.counts.failed > 0
? (this.counts.solved / (this.counts.solved + this.counts.failed)) * 100
: 0;
return {
elapsed: `${elapsed.toFixed(0)}s`,
submitted: this.counts.submitted,
solving: this.counts.solving,
solved: this.counts.solved,
failed: this.counts.failed,
avgSolveTime: `${(avgTime / 1000).toFixed(1)}s`,
throughput: `${throughput.toFixed(1)}/min`,
successRate: `${successRate.toFixed(1)}%`,
};
}
}
Beberapa sinyal dari report() yang menandakan saatnya naik paket:
- Throughput mentok padahal tingkat keberhasilan masih tinggi →
maxConcurrentsudah menyentuh batas thread, bukan batas kemampuan solve. ERROR_NO_SLOT_AVAILABLEsering muncul di log → saatnya menaikkan paket, bukan menambah retry.- Dead-letter queue terus bertambah walau
avgSolveTimenormal → cek parameter task, bukan soal kapasitas thread.
Masalah Umum dan Cara Mengatasinya
Kesalahan yang paling sering muncul setelah antrean berjalan di production:
| Gejala | Penyebab | Solusi |
|---|---|---|
| Semua promise ditolak bersamaan | Rate limit API tercapai | Turunkan maxConcurrent |
| Memory terus bertambah | Hasil terakumulasi | Proses dan hapus hasil secara berkala |
| Antrean terkuras tapi task tertinggal | Panggilan drain() tidak ada setelah selesai |
Periksa pemicu drain di blok finally |
ERROR_NO_SLOT_AVAILABLE |
Terlalu banyak panggilan API concurrent | Tambahkan jeda antar pengiriman |
| Dead-letter queue terisi | Error berulang | Periksa tipe error — mungkin perlu perbaikan parameter |
| Waktu solve melonjak di jaringan seluler | Kondisi mobile-first (umum di banyak lokasi Indonesia) menambah latensi request | Naikkan timeout polling dan maxRetries sedikit, jangan turunkan maxConcurrent |
Pertanyaan yang Sering Diajukan
Kapan sebaiknya pakai antrean prioritas, bukan concurrency queue biasa?
Kalau semua task CAPTCHA sama pentingnya — misalnya sama-sama job scraping — concurrency queue biasa sudah cukup. Pakai priority queue begitu ada task yang menahan pengalaman pengguna langsung, seperti checkout atau login, yang harus selesai lebih dulu dibanding job scraping latar belakang.
Apakah retry otomatis membuat saya membayar CaptchaAI dua kali?
Tidak. Semua paket CaptchaAI menagih berdasarkan jumlah thread aktif per bulan dengan solve tak terbatas per thread, bukan per percobaan. Retry yang wajar hanya memakai waktu thread sedikit lebih lama, bukan biaya tambahan — asal maxRetries dibatasi supaya task yang gagal permanen tidak menahan thread selamanya.
Berapa banyak thread aman dijalankan bersamaan lewat maxConcurrent?
Samakan dengan jatah thread paket Anda — 5 untuk BASIC, 15 untuk STANDARD, 50 untuk ADVANCE, dan seterusnya sesuai paket yang dipakai. Kalau ERROR_NO_SLOT_AVAILABLE sering muncul, itu sinyal maxConcurrent sudah melewati jatah thread, bukan berarti ada yang salah di aplikasi Anda.
Perlu pakai library seperti p-queue atau BullMQ, atau cukup kelas custom di atas?
Untuk satu proses dengan volume task menengah, pola bawaan di atas — ConcurrencyQueue, EventEmitter, atau PriorityQueue — sudah cukup dan lebih mudah dipahami tim kecil. Pindah ke bull/bullmq begitu Anda butuh antrean persisten berbasis Redis yang bertahan lewat restart atau dipakai bersama beberapa server.
Apakah dashboard QueueMonitor wajib dipasang di production?
Tidak wajib, tapi sangat disarankan begitu volume task naik. Tanpa angka throughput dan tingkat keberhasilan yang terekam, sulit membedakan antrean yang lambat karena jatah thread habis dari antrean yang lambat karena ada bug di kode solve Anda.
Kesimpulan: Pilih Pola Sesuai Skala Task
Node.js unggul di I/O concurrency, jadi lima pola di atas jarang perlu diganti total — biasanya Anda naik satu tingkat begitu volume task bertambah: Promise.allSettled untuk uji coba, ConcurrencyQueue begitu task tembus ratusan, EventEmitter saat butuh visibilitas real-time, priority queue begitu ada task yang harus didahulukan, dan retry queue begitu keandalan production jadi prioritas. Semuanya jalan di atas API CaptchaAI yang sama — tinggal pilih lapisan orkestrasi yang sesuai skala Anda.