Reject bad requests at the edge before they touch real logic or a database
A 40-line validator with zero dependencies handles most of my needs
Check shape, types, and size limits first, then business rules later
Returning 400 early cut wasted function runs on one endpoint by half
Most of the junk that hits an API never deserved a full response. A malformed body, a missing field, a 4MB payload where I expected 2KB. I catch all of that at the edge now, before a single line of real logic runs, and the whole backend got calmer for it.
Why the edge is the right place to say no
The edge is the first machine your request touches after DNS. On most platforms that means a worker or function running close to the user, before the request ever reaches your origin server or database. That position is the whole point.
If I validate at the origin, the bad request has already crossed the network, woken up a container, and maybe opened a database connection. I'm paying for compute to tell someone they forgot a field. If I validate at the edge, I reject it in a few milliseconds and the origin never hears about it.
There's a cost angle too.
Edge runtimes bill differently from full origin invocations, and a request that gets rejected in 3ms barely registers. I had one endpoint where roughly half the incoming traffic was malformed scanner noise and duplicate retries. Moving the shape check to the edge meant those never counted as a real function run.
The other reason is blast radius. A validation layer sitting in front of everything is a single place to enforce rules. I don't have to remember to check body.email in six different handlers. I check it once, at the door.
Edge runtimes are also deliberately limited. No full Node API, tight memory, short execution budgets. That sounds like a constraint, but it pushes me toward validation code that's small and fast, which is exactly what validation should be. If my check needs a database lookup or a heavy library, it probably isn't a shape check, it's business logic, and that belongs further in.
I keep the rule simple: the edge decides if a request is well-formed. Whether it's allowed or correct in a business sense comes later. The edge answers "is this even a sensible request" and nothing more.
That one line keeps the validator tiny. It also keeps me from dragging auth tables or inventory checks into a layer that should stay stateless.
The minimal validator I actually reuse
I don't reach for a schema library at the edge unless I have to. Most of my endpoints need maybe five checks, and a dependency-light function covers them. Here's the shape of it.
function validate(body, rules) {
const errors = [];
for (const [field, rule] of Object.entries(rules)) {
const value = body?.[field];
if (rule.required && (value === undefined || value === null)) {
errors.push(`${field} is required`);
continue;
}
if (value === undefined) continue;
if (rule.type && typeof value !== rule.type) {
errors.push(`${field} must be ${rule.type}`);
}
if (rule.max && String(value).length > rule.max) {
errors.push(`${field} exceeds ${rule.max} chars`);
}
}
return errors;
}
That's it. About 40 lines once I add a couple more checks like enum membership and a regex for email. Calling it looks like this:
const errors = validate(body, {
email: { required: true, type: "string", max: 254 },
quantity: { required: true, type: "number" },
note: { type: "string", max: 500 },
});
if (errors.length) {
return new Response(JSON.stringify({ errors }), { status: 400 });
}
The order matters. I check required first and continue so a missing field doesn't also throw a type error. A user who forgot email shouldn't get told it's the wrong type too. One clear reason per field.
I return every error at once, not the first one.
Nothing's worse than fixing a field, resubmitting, and getting hit with the next complaint. Collect them, send them back together.
For 90% of endpoints this is enough and it has zero install cost. When an endpoint genuinely needs nested objects or conditional fields, I'll pull in a real schema validator and accept the bundle size. But I reach for the small function first, and most of the time I never outgrow it.
If you want the deeper context on handling repeated and retried requests, Writing Idempotent Webhooks goes through that side.
Check the cheap things before the expensive things
Validation has an order, and getting it wrong wastes the exact compute you were trying to save.
The cheapest check is size. Before I parse anything, I look at Content-Length. If a request claims to be 10MB and my endpoint expects a small JSON body, I reject it with a 413 and never read the stream. Parsing a huge body just to discover it's huge is backwards.
Next is content type. If the header says text/plain and I need JSON, that's a 415, decided in one if. No parse attempt.
Then I parse.
I wrap the parse in a try/catch, because a broken JSON body is a client error, not a server crash. A missing closing brace should return 400, not throw a 500 and page me at night.
let body;
try {
body = await request.json();
} catch {
return new Response("Invalid JSON", { status: 400 });
}
Only after the body is a real object do I run the field validator from the last section. Shape checks are cheap but they're the last cheap thing, so they go last among the cheap things.
Everything expensive comes after all of that. Database lookups, auth token verification, rate-limit counters. By the time I'm doing any of that, I already know the request is well-formed. I'm not querying a table to then discover quantity was a string.
I learned to respect ordering the hard way on a different problem. When I measured lookup strategies in Binary Search vs Array.includes(), the lesson was the same: the order and method of a check decides whether it costs microseconds or milliseconds. Validation is no different. Put the 3ms rejections in front of the 300ms ones.
One more thing on size.
Edge runtimes have real memory limits, and a request that tries to stuff a giant string into a field is sometimes less a mistake and more a probe. Cap every string field with a max. It's a free defense against someone trying to blow up your queue or your memory.
What belongs here and what doesn't
The edge validator earns its keep by staying small. The fastest way to ruin it is to let business rules creep in.
Shape, types, and size limits belong at the edge. "Is email a string under 254 characters" is a question I can answer with no outside information. It's pure, stateless, and fast. That's the test I use: if the check needs nothing but the request itself, it can live at the edge.
Does this user actually exist? Is this coupon still valid? Is the item in stock? None of those belong here. They need a database, they need state, and they're slower by nature. Dragging them to the edge forces the edge to talk to your origin anyway, which throws away the latency win you came for.
There's a cleaner way to think about it.
The edge answers "could this request ever be valid." The origin answers "is this request valid right now, for this user, in this state." The first question is about form. The second is about truth. Keep them separate and both layers stay readable.
I also keep error messages at the edge generic on purpose. "Invalid request" plus a list of malformed fields is fine. I don't leak which internal rule failed or whether a user exists, because that validator is public-facing and anyone can poke it. Detailed, state-aware errors come from deeper in, where I've already decided the caller is allowed to know.
Watch out for edge runtime limits while you're at it. Some platforms cap execution time hard, and a validator that accidentally does real work can blow that budget. I once watched a tiny Node process fall over from a different kind of overreach in I Found the Exact Recursion Depth That Crashes Node, and the fix in both cases is the same discipline: know your limits and stay well under them.
The payoff is that the origin code gets simpler. Every handler can assume the body is well-formed, because the only way to reach it is through the door I built. That assumption removes a pile of defensive if checks from the real logic.
Bottom Line
Start with the 40-line validator and only graduate to a schema library when an endpoint genuinely needs nested or conditional rules. Order your checks cheapest-first: size, content type, parse, then shape, then anything that touches state. Keep business rules out of the edge entirely.
If you build one shared validator and run it in front of every endpoint, you get a single place to change rules and a backend that can trust its inputs. That trust is worth more than the code it costs.
I wire this pattern into most of my projects the same way I wire in a few other defaults, and I documented that whole setup in the Claude Blueprint if you want the fuller picture of how I scaffold a new service. Grab the validator, drop it at your edge, and let the junk bounce off the door.
Top comments (0)