Cloudflare waitUntil: Audit Log Tanpa Bikin Response Lambat
Nulis audit log ke D1 pakai ctx.waitUntil tanpa bikin response lambat. Terukur 306 ms vs 1,7 ms, kenapa log hilang, fix Illegal invocation. Cek kodenya.

Nunggu (await) tulis database 300 ms di dalam Cloudflare Worker bikin setiap response ikut makan 300 ms. Pindahin tulisan yang sama ke ctx.waitUntil(), response-nya turun jadi sekitar 1,7 ms di test lokal gue, dan row-nya tetep masuk setelah response terkirim. Itu inti dari audit logging yang nggak bikin request jadi lambat.
Di bawah ini ada audit wrapper yang gue pakai di production di depan sebuah API partner upstream, jalan di Cloudflare Pages Functions dengan D1, database berbasis SQLite dari Cloudflare. Lo juga bakal lihat cara nyimpen body request yang persis dikirim, dan empat alasan kenapa log bisa hilang di dalam waitUntil. Yang lo butuhin cuma project Workers atau Pages dengan binding D1. Semua test di sini jalan di Wrangler 4.124.0, CLI yang gue bandingin sama Cloudflare MCP server di Wrangler vs Cloudflare MCP Server buat Agentic AI Coding.
Polanya Cuma Sepuluh Baris
Kerjain dulu hal yang lagi ditunggu user, return response-nya, lalu serahin tulisan log ke runtime sebagai promise yang wajib dia selesaiin.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const response = await handle(request, env);
ctx.waitUntil(
env.DB.prepare("INSERT INTO audit_api_logs (endpoint, status) VALUES (?, ?)")
.bind("submit-order", response.status)
.run()
);
return response;
},
};ctx.waitUntil() adalah method Workers yang nyuruh runtime nahan sebuah promise tetep jalan setelah response terkirim, jadi dia nggak nunda response. Menurut docs context Cloudflare Workers, runtime nahan kerjaannya tetep hidup sampai 30 detik setelah response terkirim, dan jatah itu dipakai bareng sama semua panggilan waitUntil di request yang sama.
Biayanya Berapa, Await vs waitUntil
Gue simulasiin tulis database 300 ms pakai setTimeout di Worker lokal yang jalan di workerd lewat wrangler dev. Tiap route gue tembak lima request.
| Pola | Rata-rata waktu response | Nasib tulisannya |
|---|---|---|
| await tulisannya sebelum return | 306 ms | Selesai sebelum response |
ctx.waitUntil(write) | 1,7 ms | 3 dari 3 selesai setelah response |
| Floating promise, tanpa await, tanpa waitUntil | 14 ms, satu request | 0 dari 3 selesai |
Ini delay simulasi, bukan insert D1 beneran, jadi anggap bentuk polanya bisa dipercaya dan angka persisnya cuma berlaku lokal. Tulis D1 asli nambah latensi jaringan dan storage. Ukur p50 dan p95 lo sendiri sebelum nyebut angka ke orang lain.
Audit Wrapper yang Gue Pakai di Production
Wrapper-nya nerima context object dan fungsi yang manggil upstream. Dia ngitung durasi, baca status dan message yang udah bersih dari clone response, print satu baris console terstruktur, lalu jadwalin insert-nya.
// functions/_shared/audit.ts
export interface AuditContext {
db?: D1Database;
waitUntil: (promise: Promise<unknown>) => void;
endpoint: string;
subject?: string | null;
/** Filled in by the fetch helper with the exact JSON sent upstream. */
requestPayload?: Record<string, string>;
}
export async function withAuditLog(audit: AuditContext, call: () => Promise<Response>): Promise<Response> {
const startedAt = Date.now();
const response = await call();
const durationMs = Date.now() - startedAt;
const result = (await response
.clone()
.json()
.catch(() => null)) as { message?: string } | null;
const entry = {
endpoint: audit.endpoint,
subject: audit.subject ?? null,
success: response.status === 200 ? 1 : 0,
status: response.status,
message: result?.message ?? null,
duration_ms: durationMs,
request_payload: audit.requestPayload ? JSON.stringify(audit.requestPayload) : null,
};
const line = JSON.stringify({ event: "api_call", ...entry });
if (entry.success) console.log(line);
else console.error(line);
if (audit.db) {
audit.waitUntil(
audit.db
.prepare(
`INSERT INTO audit_api_logs
(endpoint, subject, success, status, message, duration_ms, request_payload)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(
entry.endpoint,
entry.subject,
entry.success,
entry.status,
entry.message,
entry.duration_ms,
entry.request_payload
)
.run()
.catch((error) => console.error(JSON.stringify({ event: "audit_log_failed", error: String(error) })))
);
}
return response;
}Ada tiga keputusan penting di sini. response.clone() bikin body asli tetep utuh buat caller. Yang dibaca cuma message yang udah bersih, jadi API key dan password nggak bakal bocor ke log. Dan .catch ada di dalam promise waitUntil, jadi kalau insert gagal, yang keluar cuma audit_log_failed dan response ke user nggak kesentuh sama sekali.
Tabelnya SQLite biasa, karena D1 itu SQLite di baliknya.
-- migrations/0011_audit_api_logs.sql
CREATE TABLE IF NOT EXISTS audit_api_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
subject TEXT,
success INTEGER NOT NULL,
status INTEGER NOT NULL,
message TEXT,
duration_ms INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_api_logs_created ON audit_api_logs (created_at DESC);Gue pengennya row yang bisa di-query seminggu kemudian, bukan live tail yang ilang begitu terminal ditutup.
Baca juga: Cara Handle 429 Rate Limit di Bulk API Request
Simpan Payload yang Persis Dikirim, Jangan Dibangun Ulang
Versi pertama tabel ini nyatet endpoint, subject, status, message dari upstream, dan durasi. Itu cukup buat tahu bahwa sebuah panggilan upstream gagal. Tapi nggak cukup buat tahu apa yang sebenernya gue kirim. Dua migration kemudian, tabelnya punya kolom request_payload.
-- migrations/0012_audit_api_logs_payload.sql
-- Exact JSON body sent upstream. NULL for rows written before this migration
-- and for calls that failed before a request was built. The API key travels
-- in a header and is never part of this payload.
ALTER TABLE audit_api_logs ADD COLUMN request_payload TEXT;D1 nggak punya tipe jsonb, jadi payload disimpen sebagai TEXT. Penangkapannya terjadi di helper yang manggil fetch, tepat sebelum request keluar, dengan nulis ke context object yang sama yang nanti dibaca wrapper.
async function postUpstream(
url: string,
apiKey: string,
payload: Record<string, string>,
audit?: AuditContext
): Promise<Response> {
if (audit) audit.requestPayload = payload;
let upstream: Response;
try {
upstream = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-KEY": apiKey },
body: JSON.stringify(payload),
});
} catch {
return Response.json({ code: 502, message: "Upstream unreachable." }, { status: 502 });
}
const result = (await upstream.json().catch(() => null)) as { code?: number; message?: string } | null;
if (!upstream.ok || result?.code !== 200) {
return Response.json(
{ code: upstream.status || 502, message: result?.message ?? "Request failed." },
{ status: upstream.ok ? 403 : upstream.status || 502 }
);
}
return Response.json({ code: 200, message: result.message }, { status: 200 });
}Kenapa payload nggak dibangun ulang di dalam wrapper aja? Soalnya kode yang bikin payload itu berubah-ubah. Gue ganti versi upstream, nilai timeout, dan field yang dikirim beberapa kali dalam satu minggu. Salinan hasil rekonstruksi bakal nyimpang dari body yang beneran dikirim, dan log-nya jadi bohong tepat di saat lo paling percaya sama dia.
Nyambungin ke Pages Function cukup beberapa baris.
export async function onRequestPost({
request,
env,
waitUntil,
}: {
request: Request;
env: Env;
waitUntil: (promise: Promise<unknown>) => void;
}): Promise<Response> {
const body = await request.json();
const audit: AuditContext = { db: env.DB, waitUntil, endpoint: "submit-order", subject: body.accountId };
return withAuditLog(audit, () => postUpstream(env.UPSTREAM_URL, env.UPSTREAM_KEY, body.payload, audit));
}Perhatiin bahwa postUpstream ngubah kegagalan jaringan jadi Response 502, bukan throw. Makanya panggilan yang gagal tetep nyampe ke logger. Kalau helper upstream lo bisa throw, bungkus await call() di wrapper pakai try dan finally, kalau nggak, kegagalan itu nggak ninggalin row sama sekali.
Kenapa Log Bisa Hilang di waitUntil?
Gue reproduksi semuanya secara lokal, jadi output di bawah ini asli.
Floating Promise Dibuang
Mulai tulisan tanpa await dan tanpa waitUntil, return response, dan tulisannya nggak pernah selesai. Gue kirim tiga request ke tiap route lalu baca counter dua detik kemudian.
{ "arrow": 3, "direct": 3 }Route dengan floating promise polos sama sekali nggak muncul, artinya 0 dari 3 tulisan selesai. Best practices Workers ngejelasin hal yang sama. Floating promise berisiko diputus, dan error-nya ketelan.
Nyalin waitUntil dari ctx Bikin Illegal invocation
Di Worker, waitUntil butuh this yang nunjuk ke execution context. Kalau lo destructure atau salin ke object lain, ini yang muncul di panggilan pertama.
TypeError: Illegal invocation: function called with incorrect `this` reference.Audit context gue persis object semacam itu, jadi di Worker versi ini gagal.
const audit = { waitUntil: ctx.waitUntil }; // Illegal invocation on first useVersi ini jalan, karena arrow function manggil method-nya di ctx sendiri.
const audit = { waitUntil: (promise: Promise<unknown>) => ctx.waitUntil(promise) };Di test gue, versi arrow nyelesaiin 3 dari 3 tulisan, sama kayak manggil ctx.waitUntil() langsung. Cloudflare nyatet ini di Illegal invocation errors.
Jatah 30 Detik Dipakai Bareng
Semua promise waitUntil dalam satu request berbagi 30 detik yang sama setelah response. Beberapa tulisan log yang lambat plus satu panggilan analytics yang lambat bisa saling kehabisan jatah. Satu promise yang ditolak nggak ngebatalin yang lain, jadi error di satu tulisan nggak bakal ngehentiin tulisan berikutnya, tapi lo nggak bakal lihat error-nya kalau nggak lo tangkap sendiri.
Kegagalan yang Ketelan dan Keluar Lebih Awal
Wrapper-nya baru nyampe ke waitUntil setelah await call() selesai. Kalau call-nya throw, nggak ada yang dijadwalin. Dan tanpa .catch di dalam, lo nggak dapet baris terstruktur yang bilang insert mana yang gagal. Dua-duanya gampang kelewat karena user ngelihat response yang kelihatan normal banget.
waitUntil di Pages Functions Sama Aja dengan di Workers?
Ide yang sama berperilaku beda tergantung di mana handler-nya hidup.
| Runtime | Posisi waitUntil | Aman di-destructure? |
|---|---|---|
| Workers | ctx di fetch(request, env, ctx) | Nggak, Illegal invocation |
| Pages Functions | Argumen context di onRequestPost | Aman, dites di Wrangler 4.124.0 |
Pages jadi pengecualian karena template worker Pages di Wrangler udah nge-bind fungsinya buat lo. Baris 157 di pages-template-worker.ts pada Wrangler 4.124.0 bunyinya waitUntil: workerContext.waitUntil.bind(workerContext). Jalanin wrangler pages dev sendiri juga ngebuktiin: handler dengan { waitUntil } di signature-nya jawab dalam 15 ms dan baris lognya muncul 301 ms kemudian.
Ini penting kalau lo mau migrasi. Pages nggak bakal dihapus. Sebuah perbandingan 2026 soal keduanya mencatat Pages masih didukung penuh sementara Cloudflare ngarahin project full-stack baru ke Workers, dan Cloudflare nerbitin panduan migrasi dari Pages ke Workers. Kalau lo beneran pindah, handler yang dulu enak aja nge-destructure waitUntil di Pages bakal throw Illegal invocation begitu dia jadi handler fetch di Workers. Kalau situs lo Astro di Cloudflare, arah Workers-first-nya gue bahas di Astro 6 Setelah Cloudflare: Kenapa Gue Upgrade.
Baca juga: Error generateStaticParams Next.js output export: Fix
Kapan waitUntil Nggak Cukup?
waitUntil itu best effort, bukan jaminan durable. Kalau isolate keburu diputus sebelum tulisan selesai, row itu hilang, dan nggak ada yang nyoba ulang.
Buat log diagnostik, itu trade-off yang wajar. Row yang hilang di tabel debugging cuma ngurangin sedikit konteks. Tapi buat catatan billing, security event, atau apa pun yang mungkin ditanyain auditor, row yang hilang itu masalah beneran. Await insert-nya sebelum lo response, atau kirim pesan ke Cloudflare Queues dari request lalu tulis row-nya di consumer.
Aturan gue sederhana. Kalau hilangnya satu entri bikin lo harus minta maaf ke seseorang, jangan taruh di waitUntil. Kalau cuma bikin debugging jadi lebih lama, pakai wrapper di atas dan biarin setiap response tetep cepet.


