Development

Cloudflare waitUntil Logging: Audit Logs Without Latency

Asep Alazhari

Log to D1 with ctx.waitUntil without slowing responses. Measured 306 ms vs 1.7 ms, plus why logs go missing and the Illegal invocation fix. See the code.

Cloudflare waitUntil Logging: Audit Logs Without Latency

Awaiting a 300 ms database write inside a Cloudflare Worker makes every response take 300 ms. Moving the same write into ctx.waitUntil() dropped the response to about 1.7 ms in my local test, and the row still landed after the response was sent. That is the whole idea behind audit logging that never slows a request down.

Below is the audit wrapper I run in production in front of an upstream partner API, on Cloudflare Pages Functions with D1, Cloudflare’s SQLite-based database. You will also see how to store the exact request body that was sent, and the four reasons logs go missing inside waitUntil. You need a Workers or Pages project with a D1 binding. Every test here ran on Wrangler 4.124.0, the CLI I compare with the Cloudflare MCP server in Wrangler vs Cloudflare MCP Server for Agentic AI Coding.

The Pattern in Ten Lines

Do the work the visitor is waiting for, return the response, and hand the log write to the runtime as a promise it has to finish.

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() is the Workers method that tells the runtime to keep a promise running after the response has been sent, so it does not delay the response. According to the Cloudflare Workers context docs, the runtime keeps the work alive for up to 30 seconds after the response is sent, and that budget is shared by every waitUntil call in the same request.

What It Costs: Await vs waitUntil

I simulated a 300 ms database write with setTimeout in a local Worker running on workerd through wrangler dev. Each route got five requests.

PatternAverage response timeWhat happened to the write
await the write before returning306 msFinished before the response
ctx.waitUntil(write)1.7 ms3 of 3 finished after the response
Floating promise, no await, no waitUntil14 ms, one request0 of 3 finished

This is a simulated delay, not a real D1 insert, so treat the shape as reliable and the exact numbers as local only. A real D1 write adds network and storage latency. Measure your own p50 and p95 before you quote a figure to anyone.

The Audit Wrapper I Run in Production

The wrapper takes a context object and a function that makes the upstream call. It times the call, reads only the sanitized status and message from a cloned response, prints one structured console line, and schedules the insert.

// 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;
}

Three choices matter here. response.clone() leaves the original body untouched for the caller. Only the sanitized message is read, so API keys and passwords cannot leak into a log. The .catch sits inside the waitUntil promise, so a failing insert prints audit_log_failed and never touches what the visitor gets.

The table is plain SQLite, since D1 is SQLite underneath.

-- 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);

I wanted rows I could query a week later, not a live tail that is gone once I close the terminal.

Also Read: Handling 429 Rate Limits in Bulk API Requests

Store the Exact Payload, Not a Rebuilt One

The first version of this table recorded the endpoint, the subject, the status, the upstream message, and the duration. That told me an upstream call failed. It could not tell me what I had actually sent. Two migrations later the table has a request_payload column.

-- 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 has no jsonb type, so the payload is stored as TEXT. The capture happens in the helper that calls fetch, right before the request goes out, by writing to the same context object the wrapper reads afterwards.

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 });
}

Why not rebuild the payload inside the wrapper? Because the code that builds a payload changes. I changed the upstream version, the timeout value, and which fields I sent several times in one week. A rebuilt copy drifts from the real body, and then the log lies to you at the exact moment you trust it.

Wiring it into a Pages Function takes four lines.

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));
}

Notice that postUpstream turns a network failure into a 502 Response instead of throwing. That is why failed calls still reach the logger. If your upstream helper can throw, wrap the await call() in the wrapper with try and finally, or those failures will leave no row at all.

Why Do Logs Go Missing in waitUntil?

I reproduced each of these locally, so the output below is real.

A Floating Promise Is Dropped

Start a write without await and without waitUntil, return the response, and the write never finishes. I sent three requests to each route and read a counter two seconds later.

{ "arrow": 3, "direct": 3 }

The route with a bare floating promise does not appear at all, which means 0 of 3 writes completed. The Workers best practices describe the same thing. A floating promise risks being cut off, and its errors are swallowed.

Copying waitUntil Off ctx Throws Illegal invocation

In a Worker, waitUntil needs its this to be the execution context. Destructure it or copy it onto another object and you get this on the first call.

TypeError: Illegal invocation: function called with incorrect `this` reference.

My audit context is exactly that kind of object, so in a Worker this version fails.

const audit = { waitUntil: ctx.waitUntil }; // Illegal invocation on first use

This one works, because the arrow function calls the method on ctx itself.

const audit = { waitUntil: (promise: Promise<unknown>) => ctx.waitUntil(promise) };

In my test the arrow version completed 3 of 3 writes, the same as calling ctx.waitUntil() directly. Cloudflare lists this under Illegal invocation errors.

The 30 Second Budget Is Shared

Every waitUntil promise in one request shares the same 30 seconds after the response. Several slow log writes plus a slow analytics call can starve each other. One rejected promise does not cancel the others, so an error in one write will not stop the next one, but you will not see it unless you catch it yourself.

Swallowed Failures and Early Exits

The wrapper only reaches waitUntil after await call() returns. If the call throws, nothing is scheduled. And without the inner .catch, you get no structured line telling you which insert failed. Both are easy to miss because the visitor sees a perfectly normal response.

Does waitUntil Work the Same in Pages Functions and Workers?

The same idea behaves differently depending on where the handler lives.

RuntimeWhere waitUntil livesSafe to destructure?
Workersctx in fetch(request, env, ctx)No, Illegal invocation
Pages FunctionsThe context argument of onRequestPostYes, tested on Wrangler 4.124.0

Pages is the exception because the Pages worker template in Wrangler binds the function for you. Line 157 of pages-template-worker.ts in Wrangler 4.124.0 reads waitUntil: workerContext.waitUntil.bind(workerContext). My own wrangler pages dev run confirmed it: a handler with { waitUntil } in its signature answered in 15 ms and its log line printed 301 ms later.

That matters if you migrate. Pages is not going away. A 2026 comparison of the two notes that Pages remains fully supported while Cloudflare steers new full-stack projects toward Workers, and Cloudflare publishes a guide for migrating from Pages to Workers. If you do move, a handler that destructured waitUntil happily on Pages will throw Illegal invocation the day it becomes a Workers fetch handler. If your site is Astro on Cloudflare, I covered the Workers-first direction in Astro 6 After Cloudflare: Why I Upgraded Now.

Also Read: Next.js output export: generateStaticParams Error Fix

When Is waitUntil Not Enough?

waitUntil is best effort, not a durable guarantee. If the isolate is cut off before the write finishes, that row is gone, and nothing retries it.

For diagnostic logs that is a fair trade. A missing row in a debugging table costs you a bit of context. For billing records, security events, or anything an auditor might ask for, a missing row is a real problem. Await the insert before you respond, or send a message to Cloudflare Queues from the request and write the row in the consumer.

My rule is simple. If losing one entry would make you apologize to someone, do not put it in waitUntil. If it would only make debugging slower, use the wrapper above and keep every response fast.

Back to Blog

Related Posts

View All Posts »
Astro 6 After Cloudflare: Why I Upgraded Now
•Development

Astro 6 After Cloudflare: Why I Upgraded Now

Cloudflare acquired Astro in January 2026. Astro 6 is the first release of that edge-first era. Here is what changed when I upgraded my own site from v5.