Technical Apr 22, 2026 · 8 min read

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.

Eduardo Lázaro
Eduardo Lázaro
Founder of ToxicFilter
Moderation on the Edge: Cloudflare Workers + ToxicFilter

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

  1. Routes only what needs moderation. Non-comment traffic passes through untouched.
  2. Applies a per-user rate limit in edge-local KV. No database round trip.
  3. Checks a content-hash cache. Duplicate messages (which are common) skip the model call entirely.
  4. Calls ToxicFilter with a secret the client never sees.
  5. Caches the decision for an hour. Watch this TTL, because you do not want stale decisions on edited content.
  6. 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.

Ready to Clean Your Content?

Start moderating in minutes on the free plan: 2,000 credits a month, no card. A check costs 1 credit, about 8 if the model reads it, about 10 for an image.