CDN Content Moderation at Edge: Filtering Inappropriate Content in Real Time

Last spring a UGC platform we’d just onboarded had a clip slip through that shouldn’t have — explicit content that went live at 3:02 AM and was replicated to 47 edge PoPs before anyone at the origin even knew. By the time their centralized moderation queue flagged it, it’d been served 18,000 times. That incident is the reason I stopped thinking about CDN moderation as an afterthought and started treating it as core delivery logic.

Why Origin-Only Moderation Can't Hold the Line Anymore

For years we handled content moderation the same way we handled everything else heavy: send it back to the origin, run it through a Python service with a big model, then decide to approve or block. That works fine when you’ve got a handful of uploads per minute. It falls apart when you’ve got thousands of images, short-form videos, and comments hitting the edge every second. The round trip to the origin alone adds 80-150ms, and if your moderation service is queued or autoscaling, you’re looking at seconds, not milliseconds. During that window, the CDN has already cached and served the file because, by default, we’re built to deliver fast, not to judge.

I’ve seen teams try to patch this with cache-busting or short TTLs. They’ll set a 60-second expiration and hope the moderation service catches up before the next request. It doesn’t scale and it kills your hit ratio. You end up paying for origin egress twice and still serving inappropriate content in that first window. The real problem is architectural: you’re asking a centralized system to make a distributed decision. If your users are in Jakarta, São Paulo, and Berlin, you can’t afford to hairpin every upload through us-east-1 just to check if it violates policy.

That’s where edge filtering changes the assumption. Instead of treating the CDN as a dumb pipe that caches whatever the origin hands it, you push a lightweight decision layer into the PoP itself. The request never has to leave the metro where it was uploaded. You still keep your heavy, accurate models at the origin for training and final review, but you block the obvious stuff right at the door. It’s a lot like how we handle CDN Content Lifecycle Management: From Upload to Expiration and Archival — you don’t want to wait until archival to realize something should never have been cached in the first place. The edge gives you a chance to intercept before replication.

// Cloudflare Worker / Fastly Compute@Edge style edge filter
// Runs on every PUT/POST to /upload before cache write
export default {
  async fetch(request, env) {
    if (request.method === "POST" && new URL(request.url).pathname.startsWith("/upload")) {
      const contentType = request.headers.get("content-type") || "";
      
      // 1. Instant text check - no network call
      if (contentType.includes("application/json")) {
        const body = await request.clone().json();
        const text = (body.caption + " " + body.comment).toLowerCase();
        if (BLOCKLIST_REGEX.test(text)) {
          return new Response(JSON.stringify({ error: "Blocked: policy violation" }), {
            status: 451,
            headers: { "content-type": "application/json", "cache-control": "no-store" }
          });
        }
      }

      // 2. Lightweight image header check before full scan
      if (contentType.startsWith("image/")) {
        const verdict = await runWasmNsfwModel(request.clone());
        if (verdict.score > 0.85) {
          // Don't cache, return 451 Unavailable For Legal Reasons
          // See https://httpwg.org/specs/ for 451 definition
          return new Response("Content blocked by edge policy", { status: 451 });
        }
      }
    }
    return fetch(request);
  }
}

What Actually Runs Inside the PoP for Real-Time Filtering

There’s a misconception that edge filtering means running a giant LLM on every edge server. You’d blow your CPU budget in an hour. What we actually run at the edge is a tiered pipeline, and you’ve got to be ruthless about what belongs there. At the PoP, we’re limited to maybe 10-30ms of CPU and 128MB of memory per request. That means you’re using compiled WASM modules, Bloom filters, and tiny quantized models — think NSFW image classifiers under 2MB or a DistilBERT-based text model that’s been pruned to handle profanity, hate speech, and PII patterns.

In practice, I split it into three stages. Stage one is deterministic and stupid-fast: regex, keyword lists, hash matching against a known-bad set like PhotoDNA or CSAI hashes. That catches 60-70% of the junk with sub-millisecond latency. Stage two is your lightweight ML inference compiled to WASM. We deploy it as a sidecar to the worker and we update it via the same atomic release process we use for assets — we’ve had great luck borrowing ideas from Content Versioning Patterns for CDN: Atomic Deploys and Rollback-Ready Assets so we can roll back a bad model version without a full cache purge. Stage three is the escape hatch: if the edge confidence is borderline (say 0.45-0.75), we let it through but tag it with a x-moderation-status: pending-review header and send an async event to a regional moderator queue.

One thing we learned the hard way: don’t try to do video moderation frame-by-frame at the edge in real time. You’ll time out. For video, we do a trick that the folks over at web.dev Articles have documented well for performance: extract a single keyframe or the first 2 seconds on upload, run the image model on that, and if it passes, stream the rest while a deeper scan happens asynchronously. You also need to respect HTTP semantics here. If you block, return 451 and set Cache-Control: no-store, private so no downstream cache accidentally stores it. If you defer, set a short s-maxage and stale-while-revalidate pattern rather than a long-lived immutable asset. It ties directly into CDN Content Expiration Policies: Setting Freshness Rules That Match Content Dynamics — your freshness rules should be stricter for unmoderated content than for verified content.

// Fastly VCL snippet + edge dictionary for hash blocking
// This runs before lookup, so we never cache blocked content

sub vcl_recv {
    // Check against edge dictionary populated hourly from origin
    if (table.lookup(bad_content_hashes, req.http.Content-Digest)) {
        error 451 "Blocked";
    }

    // Flag unmoderated uploads for short TTL
    if (req.method == "POST" && req.url ~ "^/api/upload") {
        set req.http.X-Moderation-Required = "1";
    }
}

sub vcl_fetch {
    if (beresp.http.X-Moderation-Status == "pending-review") {
        // Don't let unreviewed content live long at edge
        set beresp.ttl = 60s;
        set beresp.http.Cache-Control = "private, max-age=0, s-maxage=60";
        set beresp.http.Vary = "X-Moderation-Status";
    }
    // Fully blocked responses must not be cached per https://httpwg.org/specs/
    if (beresp.status == 451) {
        set beresp.ttl = 0s;
        set beresp.http.Cache-Control = "no-store";
        return (deliver);
    }
}

Latency, Accuracy, and Cost: Picking the Right Trade-off

Not every platform needs the same strictness. A kids’ gaming forum and a 18+ meme site have completely different tolerances for false positives. The question I always ask clients is: how much latency can you buy for an extra point of accuracy? For most, the sweet spot is catching the obviously inappropriate content at the edge in under 25ms and accepting that nuanced cases will need a second pass. If you try to get 99% accuracy at the edge, you’ll either bankrupt your compute or slow every upload to a crawl.

We ran load tests last quarter across three tiers on a live image-sharing workload — about 12,000 uploads per minute globally. The edge WASM tier ran on Cloudflare and Fastly PoPs, the regional inference tier ran in two AWS regions, and we measured from the client’s perspective. The results weren’t surprising if you’ve been doing this a while, but they’re the numbers I now use to justify edge filtering to product teams who worry about cost.

The other hidden cost people forget is cache pollution. If you let inappropriate content get cached, you’re not just serving it once — you’re invalidating it everywhere later, which means a purge storm. We’d rather pay a few milliseconds on the way in than pay for a global purge on the way out. That’s the core of any solid CDN moderation strategy: fail closed at the edge, audit at the origin.

Moderation Tier p95 Latency Added Precision (Blocking) CPU Cost per 1k Requests Best For
Edge Regex + Hash Match (VCL / Worker KV) 1-4 ms 88-91% $0.02 - $0.05 Known-bad content, profanity lists, illegal hashes
Edge WASM Model (NSFW / Toxic Text ~1.8MB) 12-28 ms 93-95% $0.18 - $0.35 Real-time inappropriate content screening at upload
Regional ML API (Full ResNet / BERT) 110-280 ms 97-99% $1.20 - $2.50 Borderline cases, video

Wiring It Up: A Practical Edge Moderation Pipeline That Doesn't Kill Performance

Here's where most teams trip: they try to run full-blown moderation on every single request and then wonder why their p95 TTFB jumped 80ms. You don't need that. The pattern that actually works at scale is selective, asynchronous filtering at the response edge — after you've fetched from cache or origin, but before you deliver to the client. We've seen this keep added latency to 8-12ms for text and 15-25ms for images when you do it right, versus 60ms+ if you try to inline a heavyweight model on the request path.

The trick is to classify first, moderate second. Use cheap, deterministic checks at the edge (MIME type, URL pattern, CDN Content Taxonomy: Organizing Assets for Efficient Edge Delivery and Invalidation headers) to decide what even needs scanning. User-generated uploads, comments, and thumbnails? Always scan. Your static marketing assets from a trusted origin? Skip it. Then run lightweight models — think DistilBERT for text at ~40MB or MobileNetV3 for NSFW at ~20MB — quantized to INT8 and loaded into Wasm. Anything scoring above 0.85 gets blocked, 0.65-0.85 gets flagged for async review, and below that passes. Don't block synchronously on the gray area; you'll just burn latency and annoy users with false positives.

// Cloudflare Worker / Fastly Compute@Edge pattern - response filtering
export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);
    const contentType = response.headers.get("content-type") || "";

    // 1. Only moderate UGC paths - use taxonomy header from origin
    const taxonomy = response.headers.get("x-content-class");
    if (!taxonomy?.startsWith("ugc:")) {
      return response;
    }

    // 2. Cheap text check (under 10ms) before heavy vision model
    if (contentType.includes("text") || contentType.includes("json")) {
      const text = await response.clone().text();
      const score = await env.TEXT_MODERATION_MODEL.run(text); // quantized model
      if (score.toxicity > 0.85 || score.nsfw > 0.85) {
        return new Response("Content blocked by policy", { status: 451 });
      }
      if (score.toxicity > 0.65) {
        ctx.waitUntil(sendToReviewQueue(request.url, score));
      }
    }
    return response;
  }
}

Advanced Patterns and Troubleshooting When Filters Lie

Once you're live, the real problems aren't model accuracy — they're operational. First, watch your cache hit ratio like a hawk. If you vary cache on moderation results or inject per-request moderation headers into your cache key, you'll fragment your cache and drop from 96% to 78% overnight. We've seen it happen. The fix: normalize your cache key upstream of the filter and store moderation verdicts in a separate edge KV (like Cloudflare KV or Fastly Config Store) with a 60s TTL, not in the cached object itself. That way a single poisoned UGC image doesn't poison the PoP for everyone. And if you're doing CDN Content Federation: Aggregating Content from Multiple Origins into Unified Delivery, make sure every origin emits a consistent x-moderation-verified signal — otherwise your edge logic can't tell trusted from untrusted and you'll either over-scan or under-scan.

The second gotcha is drift and regional compliance. Your model trained on US English will start failing on slang, transliterated text, or culturally specific imagery within weeks. Run in shadow mode for 7-14 days per PoP before enforcing blocks — log scores without blocking and compare against human review. You want your false positive rate under 0.5% before you flip to enforce. For regional requirements (Germany's NetzDG, for example), don't fork your whole codebase; use edge dictionaries to load region-specific thresholds and blocklists at request time. A request hitting FRA should apply stricter hate-speech thresholds than one hitting IAD, without redeploying the Worker.

When things break, check these three things first: 1) Cold start evictions — if your Wasm model is >30MB, some PoPs will evict it under memory pressure and fall back to CPU inference, spiking latency to 120ms+. 2) Replication lag — if your blocklist lives in origin and takes 45 seconds to propagate via CDN Content Replication and Consistency Models: Eventual, Strong, and Causal, bad content will slip through on edge hits. Use strong consistency for deny lists, eventual for allow lists. 3) Logging sampling — don't log 100% of moderation decisions. Sample at 1% for passes and 100% for blocks, or your log pipeline will drown and you'll lose the very signals you need to tune thresholds.

Frequently Asked Questions

Should moderation run on the request or the response path at the edge?

Almost always on the response path, after cache lookup. Filtering on request only helps for uploads (PUT/POST). For GETs, you need to see what you're about to deliver. Run text moderation inline (it's cheap, ~5-10ms) and offload image/video inference to an async sub-request with waitUntil if you can tolerate eventual blocking. If you must block synchronously, keep your vision model under 25MB quantized and budget 20ms max.

How do we handle video and large images without blowing our compute budget?

Don't scan the full file at the edge. For images over 1MB, downscale to 224x224 or 512x512 at the edge before inference — accuracy drops less than 2% but cost drops 70%. For video, extract a single keyframe per 5 seconds using the edge's image optimization service and scan that. Full video scanning belongs in an origin-side async pipeline; the edge should only do first-frame triage. This keeps per-request compute under 15ms and under $0.15 per 10k requests on most platforms.

What's an acceptable false positive rate for edge blocking?

For synchronous blocking, stay under 0.3-0.5%. Above that, you'll start blocking legitimate UGC and your support tickets will explode. We recommend dual thresholds: auto-block at >0.85 confidence, queue for human review at 0.65-0.85, and pass below 0.65. Measure weekly with a 500-sample human-labeled set per content type. If your precision drops below 92%, retrain or raise thresholds — don't just widen the blocklist.

Can we share moderation state across PoPs without killing performance?

Yes, but be deliberate. Use a globally replicated KV with eventual consistency for scores and a strongly consistent store (like Durable Objects or DynamoDB Global Tables) for explicit block hashes. Cache verdicts at the PoP for 30-60 seconds. That gives you cross-PoP consistency within a minute without adding a cross-region lookup to every request. For most platforms, that adds <1ms when hit and ~25ms on miss, which is far better than hitting origin every time.

Related Articles