WritingBots & Automation

Clustering Bot Traffic by JA4 Fingerprint, Not IP or User-Agent

2026-08-31

Problem

We needed to identify and profile clusters of automated/scraper traffic from a large volume of raw HTTP request logs — well past what grouping by IP address or user-agent alone could make sense of.

Symptoms

Observed

Root cause (working theory)

We believe two separate constraints were being conflated as one problem. First, IP address and user-agent are cheap for a traffic source to vary — residential proxy pools and header spoofing make both unreliable grouping keys — while a JA4 fingerprint reflects the client's actual TLS stack and holds far more consistently per source. Second, and unrelated to the grouping logic entirely, a Worker invocation is capped in CPU time and carries no memory across requests, so a full-day scan can't run as a single shot no matter how the grouping itself is implemented.

Why the obvious fix failed

The first version was a single request that listed every log file for the target day, decompressed and parsed each one, and aggregated results in memory before returning. It didn't scale past a small slice of a day's traffic: the CPU/wall-clock budget ran out well before the file list did, and because the aggregation only existed in that request's memory, a timeout meant starting over from nothing rather than resuming.

Fix

Split the scan into bounded batches (150 log files per call) and moved every piece of state that needs to survive between calls — the full sorted file list, how far the scan had progressed, and the running per-cluster aggregation — into a KV namespace kept separate from the log data itself. R2 stayed a read-only, append-only source for the raw logs; KV became the only thing that persisted between invocations. A Cron Trigger re-issues the same scan call on a timer, so a full day's scan completes as a sequence of short invocations that each pick up exactly where the previous one left off.

Verify

Interrupting a scan partway through and triggering it again resumed from the stored cursor rather than restarting, and produced the same final per-cluster aggregation as an uninterrupted run. Aggregated results were also readable at any point mid-scan, since they live in KV rather than only materializing once a run finishes.

Takeaway

When a job is too large for one Worker invocation's budget, don't try to shrink the job to fit — checkpoint it. Keep the durable input (R2, read-only) separate from the durable progress state (a KV cursor plus aggregation), process bounded batches per call, and let a Cron Trigger drive it forward. And when the goal is grouping automated traffic rather than just counting it, group by a signal that's expensive for the source to vary — a JA4 TLS fingerprint holds far more consistently across requests than IP address or user-agent ever will.