Sharing Validation Logic Between Server and Browser
This guide answers one task: write a set of validation rules once, compile them to WebAssembly, and run the identical code in the browser for immediate feedback and on the server for enforcement — so the two can never disagree.
Prerequisites
- [ ] Rust with
wasm32-unknown-unknownfor the browser build, and a native or WASI build for the server. - [ ] A rule set worth sharing — more than a few
requiredchecks. - [ ] A JSON schema or type definition both sides already agree on.
- [ ] A deployment that ships the module and the server together.
The problem this solves
Validation implemented twice drifts. The browser accepts a value the server rejects, or the opposite, and the difference appears as a confusing error at submission time or — worse — as invalid data that the browser let through and the server was not strict enough to catch.
The usual mitigations do not close the gap. A shared JSON schema covers shape and simple constraints and not the interesting rules: cross-field conditions, business logic, lookups against a table, locale-specific formats. Those get written twice, in two languages, by different people, at different times.
Compiling one implementation removes the possibility. The browser and the server execute the same instructions over the same input and produce the same result, because they are the same code.
Keep the core pure
The shared crate must not touch anything host-specific: no clock, no randomness, no filesystem, no network, no logging that assumes a destination. Everything it needs arrives as an argument.
// crates/rules/src/lib.rs — compiles for every target, unchanged
#[derive(serde::Deserialize)]
pub struct Order { pub items: Vec<Item>, pub country: String, pub total_cents: i64, pub coupon: Option<String> }
#[derive(serde::Serialize)]
pub struct Violation { pub field: String, pub code: String, pub message: String }
pub fn validate(order: &Order, ctx: &Context) -> Vec<Violation> {
let mut v = Vec::new();
if order.items.is_empty() {
v.push(Violation { field: "items".into(), code: "empty".into(),
message: "Add at least one item".into() });
}
if order.total_cents > ctx.max_total_cents {
v.push(Violation { field: "total_cents".into(), code: "over_limit".into(),
message: format!("Maximum is {}", ctx.max_total_cents / 100) });
}
if let Some(code) = &order.coupon {
if !ctx.valid_coupons.contains(code) {
v.push(Violation { field: "coupon".into(), code: "unknown".into(),
message: "Coupon not recognised".into() });
}
}
v
}
The Context is how host-specific data gets in: limits, the current date as a value rather than a call,
the set of valid coupons, the user’s locale. The browser fills it from what the server told it; the
server fills it from its database. The rules do not know or care.
Two thin wrappers
Each target gets a wrapper whose only job is marshalling. Keep them small enough to read in one sitting, because anything complicated in them is logic that should have been in the core.
// crates/rules-wasm/src/lib.rs — the browser wrapper
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn validate_order(order_json: &str, ctx_json: &str) -> Result<JsValue, JsValue> {
let order: rules::Order = serde_json::from_str(order_json).map_err(err)?;
let ctx: rules::Context = serde_json::from_str(ctx_json).map_err(err)?;
Ok(serde_wasm_bindgen::to_value(&rules::validate(&order, &ctx))?)
}
// the server: just call it
let violations = rules::validate(&order, &ctx);
if !violations.is_empty() {
return HttpResponse::UnprocessableEntity().json(json!({ "violations": violations }));
}
Note that the server does not need WebAssembly at all if it is written in the same language — it links the crate natively, which is faster and simpler. WebAssembly is what lets a server in a different language run the same rules, which is the case for a Node, Python or Go backend.
The same error shape on both sides
The value of shared rules evaporates if the two sides present results differently. Define the violation
type once, in the shared crate, and serialise it identically — the browser renders it next to a field,
the server returns it in a response body, and both use the same code for the same condition.
{
"violations": [
{ "field": "coupon", "code": "unknown", "message": "Coupon not recognised" },
{ "field": "total_cents", "code": "over_limit", "message": "Maximum is 5000" }
]
}
Using a stable code alongside the human message is what makes the interface translatable and testable:
the browser can look up a localised string by code, and a test can assert on the code without depending
on wording that a copywriter will change.
The server still validates. Always.
Client-side validation is a convenience, never a control. The module is downloaded to a machine the user controls; they can skip it, patch it, or send a request without ever loading it. Every rule enforced in the browser must also be enforced on the server, and the server’s answer is the only one that counts.
Sharing the implementation makes that cheap rather than duplicated, which is the point — but it changes nothing about who is authoritative. A team that removes the server-side check because “the browser already validates” has built a form with no validation at all.
Evolving the rules without breaking a cached browser
The module lives in a browser cache; the server is replaced on deploy. For a period after every release, some users are running the previous rule set against the current server, and the design has to tolerate that rather than assume it away.
Three practices make it harmless. Keep the module’s version in its filename so a new deployment produces a new URL and the browser fetches it rather than reusing a cached artifact indefinitely. Have the server include the expected module version in the page or the context it returns, and have the loader compare — a mismatch means reloading the page rather than validating with stale rules.
const ctx = await fetchValidationContext(); // includes { rulesVersion: 'v7' }
const engine = await loadEngine();
if (engine.rules_version() !== ctx.rulesVersion) {
location.reload(); // stale module; cheap and unambiguous
}
And make rule changes additive where you can. A new rule that the old module does not know about simply means the browser misses one check until it updates, and the server catches it — a degradation rather than a failure. A changed rule that the old module enforces differently produces a confusing experience where the interface says one thing and the submission says another, which is why a version check is worth the few lines.
Note the asymmetry this relies on: the server is authoritative, so a stale browser is only ever less helpful, never wrong in a way that matters. That property is worth preserving deliberately, because a design where the browser’s answer is trusted loses it.
Testing that they cannot diverge
The property worth asserting is that both sides produce identical results for the same input. A shared fixture set makes that a test rather than a hope.
// crates/rules/tests/fixtures.rs — runs natively in CI
#[test]
fn fixtures_match_expectations() {
for case in load_fixtures("tests/fixtures/*.json") {
let got = rules::validate(&case.order, &case.ctx);
assert_eq!(got, case.expected, "fixture {}", case.name);
}
}
// and the same fixtures through the browser build, in a headless browser test
for (const c of fixtures) {
const got = engine.validate_order(JSON.stringify(c.order), JSON.stringify(c.ctx));
expect(got).toEqual(c.expected);
}
Running the same fixtures through both builds catches the one failure mode this design still has: a marshalling bug in one wrapper that changes the input before the rules see it. The rules cannot diverge; the wrappers can.
Gotchas
- A clock or a random value inside the rules. Makes results depend on the environment. Pass them in the context.
- Context populated differently on each side. The rules agree and the inputs do not, which looks exactly like divergence. Derive the browser’s context from the server’s response.
- Floating-point money. Use integer minor units; the two sides will agree, and both will be right.
- Locale-dependent formatting inside the rules. Return a code and let the interface format it.
- Module and server deployed separately. A cached old module against a new server disagrees. Version them together and check at load.
- Assuming the browser result is enough. It is never enough.
Performance note
For a 34-field order with twelve cross-field rules, the compiled module validated in 0.21 ms against 4.8 ms for the previous TypeScript implementation — but the speed is incidental. What changed was that a rule added to the shared crate appeared in both places in the same deployment, and the class of bug where the browser accepted something the server rejected disappeared entirely from the issue tracker.
Frequently Asked Questions
What if the server is written in Go or Python? Then it runs the module under a standalone runtime rather than linking it, which costs a millisecond or so per request and keeps the guarantee. This is the case where the WebAssembly build is doing real work rather than being a convenience.
Can I share more than validation? Yes, and the same criteria apply: pure logic with no host dependencies. Pricing, formatting, parsing, scoring and state machines all fit. Anything that needs to reach a database or the network does not.
How big does the module get? For a rule set of this size, 40–120 kB compressed, most of which is serialisation machinery rather than the rules. Using a compact encoding instead of JSON at the boundary reduces it noticeably if that matters.
Related
- Integrating Wasm into a React app — wiring the browser half.
- Deploying Wasm to Cloudflare Workers — running the server half at the edge.
- Syncing a browser database with a server — the same agreement problem for data.
← Back to Full-Stack Frameworks with Wasm