Moderation on the Edge: Cloudflare Workers + ToxicFilter
Run a thin moderation proxy at the edge. Lower latency, smaller blast radius on outages, and a simpler client-side integration.
Running moderation at the edge is a deceptively elegant pattern: the moderation call happens physically close to the user, the client never talks directly to the moderation vendor, and your origin sees only clean content. It is also a great way to reduce latency, shrink your attack surface, and simplify the client SDK.
This post walks through a Cloudflare Worker that sits in front of ToxicFilter. The same pattern works with Vercel Edge Functions, Deno Deploy and AWS Lambda@Edge with minor adjustments.
Why the edge for moderation?
- Latency. The worker runs in hundreds of cities. Round-trip from a user in São Paulo to a worker in São Paulo then to a regional moderation cluster in São Paulo is faster than the user reaching your US origin at all.
- Security. Your ToxicFilter API key lives in Worker Secrets, never in the client. If an attacker decompiles your mobile app, they find no keys.
- Rate limiting and caching. The edge is a natural place for both. Use Cloudflare KV or Durable Objects for per-user quotas, and the built-in Cache API for content-hash moderation caching.
- Simpler clients. Your iOS, Android and web clients all talk to one URL with one auth scheme: your own.
A minimal Worker
// wrangler.toml
name = "moderation-edge"
main = "src/index.ts"
compatibility_date = "2026-01-01"
[vars]
ORIGIN_URL = "https://api.yourapp.com"
# Secrets (set with `wrangler secret put`):
# TOXICFILTER_API_KEY
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== '/comments' || request.method !== 'POST') {
return fetch(request);
}
const body = await request.clone().json<{ text: string }>();
// Per-user rate limit
const userId = request.headers.get('X-User-Id') ?? 'anon';
const rateOk = await checkRate(env, userId);
if (!rateOk) return new Response('Too Many Requests', { status: 429 });
// Content-hash cache
const hash = await sha256(body.text);
const cached = await env.CACHE.get(`mod:${hash}`);
if (cached) {
const { blocked } = JSON.parse(cached);
if (blocked) return new Response('Blocked', { status: 422 });
return fetch(request);
}
// Call moderation
const modRes = await fetch('https://api.toxicfilter.com/v1/moderate/text', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.TOXICFILTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: body.text }),
});
const mod = await modRes.json<ModResult>();
await env.CACHE.put(
`mod:${hash}`,
JSON.stringify({ blocked: mod.blocked }),
{ expirationTtl: 3600 }
);
if (mod.blocked) {
return new Response(
JSON.stringify({ error: 'blocked', reason: mod.reason }),
{ status: 422, headers: { 'Content-Type': 'application/json' } }
);
}
// Forward the original request to your origin
return fetch(request);
}
};
What the Worker is doing, step by step
- Routes only what needs moderation. Non-comment traffic passes through untouched.
- Applies a per-user rate limit in edge-local KV. No database round trip.
- Checks a content-hash cache. Duplicate messages (which are common) skip the model call entirely.
- Calls ToxicFilter with a secret the client never sees.
- Caches the decision for an hour. Watch this TTL, because you do not want stale decisions on edited content.
- Returns a clean error if blocked, or forwards to the origin if approved.
Gotchas we learned the hard way
1. Cold starts are real
Even "isolate-based" edge runtimes have cold starts on previously idle regions. Budget 5 to 15ms for the first request after idle. If you are in sub-50ms territory this matters.
2. Streaming requests
For image uploads, you cannot clone() a large request body in memory. Use a streaming approach and hash as you go, or terminate uploads at the edge only for small payloads.
3. Observability is different
You lose your regular APM out-of-the-box. Use the edge platform's logging (Cloudflare Logpush, Vercel Observability) or ship events to a SaaS explicitly. "It worked in staging" is less meaningful when every request happens in a different location.
4. Fail-open vs. fail-closed at the edge
What happens when ToxicFilter is briefly unreachable from that edge region? You have the same decision as in the rate-limiting post, except now it lives in JavaScript running on Cloudflare infrastructure rather than in your own stack. Make the choice explicit; do not inherit the default.
When edge moderation is the wrong choice
- Heavyweight media. Moderation of video, large audio or high-resolution images usually needs the moderation provider's own ingestion pipeline, not a proxy at the edge.
- Regulated data residency. If your users' content must never leave a specific region, make sure your edge provider and moderation provider both offer region pinning.
- Internal-only apps. If everything is behind a VPN anyway, you lose most of the edge benefits.
For everything else (public chat, comments, reviews, profiles) the edge pattern is a quiet win: faster, safer, simpler. The same fifty lines of Worker code tend to outlast three versions of your monolith.
Keep reading
Webhooks vs. Synchronous Calls: When to Use Each for Moderation
Blocking the user until moderation completes is not always the right answer. A decision framework for sync, as...
How to Moderate User-Uploaded Images Before Saving Them to S3
Moderating after upload means the unsafe content already exists on your infrastructure. Here is how to flip th...
Rate Limiting and Retries: Best Practices When Calling a Moderation API
Exponential backoff, jitter, circuit breakers, and what to do when the API is briefly unavailable. Keep your a...