Where Does Code Run — and Why Does That Distinction Matter?
Every web application is really two programs: one that runs on infrastructure you control (the server), and one that runs on a machine you don't control at all — a stranger's browser, on a stranger's operating system, with a stranger's browser extensions and developer tools attached. That second program ships as plain text. Anyone who receives it can read it, modify it, and re-run it however they like.
This is not a performance detail. It's a trust boundary. Anything you compute or validate only on the client is a suggestion, not a guarantee — the server is the only environment where you can actually enforce a rule, because it's the only environment an attacker cannot rewrite before it executes.
/*
* BEFORE — the discount rule exists only in the browser.
* It looks correct in the UI. It enforces nothing.
*/
// Client-side JavaScript — this is the ONLY place this check exists
function calculateTotal(price, quantity, couponCode) {
let total = price * quantity;
if (couponCode === 'SAVE20') {
total = total * 0.8; // 20% off — computed in the browser
}
return total; // sent to the server as "the total to charge"
}
// Server — trusts whatever number the client sent
@PostMapping("/api/checkout")
public OrderResponse checkout(@RequestBody CheckoutRequest request) {
return orderService.charge(request.total()); // 💀 charges whatever number arrived
}
// A user with DevTools open, or simply calling the endpoint with curl,
// sends { "total": 0.01 } directly. Nothing on the server disagrees.
/*
* AFTER — the browser still computes a total for instant UI feedback,
* but the server independently recomputes it from data IT trusts
* (its own product catalog and coupon table) and ignores the client's number.
*/
@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {
private final PricingService pricingService; // constructor injection
public CheckoutController(PricingService pricingService) {
this.pricingService = pricingService;
}
@PostMapping
public OrderResponse checkout(@Valid @RequestBody CheckoutRequest request) {
// The client's total is never read. The server derives the real one
// from the product price and coupon validity stored in ITS database.
BigDecimal realTotal = pricingService.computeTotal(request.productId(), request.quantity(), request.couponCode());
return orderService.charge(realTotal);
}
}
public record CheckoutRequest(Long productId, @Positive int quantity, String couponCode) {}
What Belongs on Each Side
| Task | Runs on | Why |
|---|---|---|
| Database access | Server | The client has no network path to your database and must never be given one |
| Password hashing, token generation | Server | Secrets and cryptographic keys cannot exist in code you ship to a browser |
| Authorization checks ("can this user do this?") | Server | A check the client can skip is not a check |
| Business rules (pricing, eligibility, limits) | Server | Must be enforced identically for every client, and be unbypassable |
| UI rendering, DOM updates | Client | The user's screen is, by definition, in the user's browser |
| Form validation for user feedback | Client (in addition to server) | Instant feedback with no network round-trip — but never a substitute for server validation |
| Animations, transitions | Client | Zero latency requirement; a round-trip would make them stutter |
Server-Side — Runs on Infrastructure You Control
// Every line here executes on YOUR machine. The client never sees this code,
// only the JSON it returns.
@RestController
@RequestMapping("/api/users")
public class RegistrationController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public RegistrationController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public UserResponse register(@Valid @RequestBody RegisterRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new EmailAlreadyRegisteredException(request.email());
}
String hashed = passwordEncoder.encode(request.password()); // hashing NEVER happens client-side
User saved = userRepository.save(new User(request.email(), hashed));
return new UserResponse(saved.getId(), saved.getEmail());
}
}
public record RegisterRequest(@Email String email, @Size(min = 10) String password) {}
public record UserResponse(Long id, String email) {}
Client-Side — Runs Inside the User's Browser
The example below is plain JavaScript on purpose — the split described in this topic exists independently of any frontend framework. React, Vue, and Angular formalize this pattern with components and reactive state, but the underlying rule (this code runs in the browser, and only the browser) is identical either way.
// This executes in the browser — the user's CPU, the user's memory.
const form = document.getElementById('registrationForm');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
// Client-side validation — purely for immediate feedback.
// It improves UX; it enforces NOTHING. The server repeats every check.
if (!email.includes('@')) {
showFieldError('email', 'Enter a valid email address');
return;
}
if (password.length < 10) {
showFieldError('password', 'Password must be at least 10 characters');
return;
}
const response = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
showFieldError('email', 'This email is already registered');
return;
}
window.location.href = '/welcome';
});
The Complete Picture: A Login Request End to End
Following one request across the boundary makes the split concrete. Note where the authentication token ends up — this is the part most tutorials get wrong.
// ── STEP 1: CLIENT — user submits the form ──────────────────────────
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // tells the browser to store/send the cookie the server sets
body: JSON.stringify({ email, password })
});
// ── STEP 2: SERVER — the only place identity is actually verified ───
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
}
@PostMapping("/login")
public ResponseEntity<Void> login(@Valid @RequestBody LoginRequest request) {
User user = userRepository.findByEmail(request.email())
.filter(u -> passwordEncoder.matches(request.password(), u.getPasswordHash()))
.orElseThrow(InvalidCredentialsException::new); // same error for "no such user" and "wrong password"
String token = jwtService.generateToken(user);
// The token is set as an httpOnly cookie. JavaScript in the browser
// can never read this value — that's the point (see Section 3).
ResponseCookie cookie = ResponseCookie.from("auth_token", token)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/")
.maxAge(Duration.ofHours(1))
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
}
public record LoginRequest(@Email String email, @NotBlank String password) {}
// ── STEP 3: CLIENT — the browser already stored the cookie automatically ─
if (response.ok) {
window.location.href = '/dashboard';
// No token to read, no token to store manually. Every subsequent
// fetch() with credentials: 'include' sends the cookie automatically.
} else {
showError('Invalid email or password');
}
Security: Never Trust the Client — Including Where You Store Its Secrets
Form fields, JavaScript validation results, HTTP headers, cookies
without HttpOnly, and anything in localStorage
are all fully under the user's control. Client-side checks exist for
user experience, never for enforcement.
// The attack requires no special tooling — just curl.
curl -X POST https://shop.example.com/api/checkout \
-H "Content-Type: application/json" \
-d '{"productId": 1, "quantity": 1, "total": 0.01}'
// If the server in Section 0's "BEFORE" example is still live,
// this charges one cent for any product. The fix is not "add more
// JavaScript validation" — it's "never read request.total() at all."
A common but flawed pattern is receiving a JWT in the JSON response
body and storing it with
localStorage.setItem('token', token). Any script that
runs on your page — including one injected through an XSS
vulnerability in a dependency you didn't audit — has full read
access to localStorage and can exfiltrate that token.
Setting the token as an HttpOnly cookie, as in Section 2,
removes it from JavaScript's reach entirely: a successful XSS
payload still can't read it. This does trade away one thing — the
cookie is automatically sent on cross-site requests unless
SameSite is set, which is why the example above pairs
HttpOnly with SameSite=Strict. Full CSRF
and token-theft trade-offs are covered in
JWT (JSON Web Tokens).
Where the HTML Gets Built: SSR, CSR, and Hybrid Approaches
Server vs client execution isn't only about business logic and security — it also determines where the HTML itself is assembled. This is a separate axis from everything above, and it's usually decided per-application, not per-request.
Server-Side Rendering (SSR)
// Thymeleaf — the server sends complete, ready-to-paint HTML
@GetMapping("/products")
public String productList(Model model) {
model.addAttribute("products", productService.findAll());
return "products"; // resolves to products.html, rendered server-side
}
<!-- products.html — filled in BEFORE the browser ever sees it -->
<ul>
<li th:each="product : ${products}">
<span th:text="${product.name}"></span>
</li>
</ul>
// The browser receives finished HTML — no JavaScript required to see content.
Client-Side Rendering (CSR)
// Server — a pure JSON API, no HTML at all
@GetMapping("/api/products")
public List<ProductResponse> getProducts() {
return productService.findAll();
}
// Client — plain JavaScript builds the DOM after fetching JSON
async function renderProductList() {
const response = await fetch('/api/products');
const products = await response.json();
const list = document.getElementById('product-list');
list.innerHTML = products
.map(p => `<li>${p.name}</li>`)
.join('');
}
// React, Vue, and Angular automate exactly this fetch-then-render cycle
// with reactive state instead of manual innerHTML rewrites.
| Aspect | SSR | CSR |
|---|---|---|
| Initial paint | Fast — HTML arrives ready | Slower — blank page until JS loads and fetches data |
| SEO | Content visible to crawlers by default | Requires prerendering or a crawler that executes JS |
| Navigation after first load | Full page reload per navigation (unless paired with HTMX/Turbo) | Instant, no reload (SPA) |
| Server load | Higher — renders markup per request | Lower — server only serves JSON |
| Java tooling | Thymeleaf, JSP | None — any JSON API plus a frontend framework |
Hybrid: Server HTML Fragments Without Full Reload
The dominant pattern in 2026 is not "pick SSR or CSR" but combine them: render the initial page on the server for speed and SEO, then swap only the fragments that change. HTMX is the clearest example of this in a Java/Spring stack — no JavaScript framework required.
@GetMapping("/products/search")
public String searchProducts(@RequestParam String query, Model model) {
model.addAttribute("products", productService.search(query));
return "fragments/product-list"; // only the fragment, not the full page
}
<!-- The server renders HTML; HTMX swaps it in without a page reload -->
<input type="search"
hx-get="/products/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results">
<div id="results"></div>
Modern React (via Next.js) and similar meta-frameworks push this further with Server Components — components that execute exclusively on the server, never ship their code to the browser, and can access databases directly, alongside Client Components that hydrate for interactivity. It's the same server/client trust boundary from Section 0, formalized at the component level rather than the endpoint level. None of it changes the core rule: code shipped to the browser is public and unenforceable; code that stays on the server is not.
Best Practices and Common Pitfalls
✅ Do
- Validate every rule that matters on the server, regardless of what the client already checked
- Recompute prices, totals, and eligibility server-side from your own data — never trust a number the client sends back to you
- Keep client-side validation for what it's good at: instant, disposable feedback
- Store authentication tokens in
HttpOnlycookies when the client is a browser, not inlocalStorage - Choose SSR, CSR, or a hybrid deliberately based on SEO and interactivity needs — not by default habit
❌ Don't
- Don't treat client-side validation as security — it is UX, and it is always bypassable
- Don't put API keys, database credentials, or signing secrets in any code shipped to the browser, including JavaScript bundles
- Don't store JWTs in
localStorageif anHttpOnlycookie is an option — it trades XSS resistance for a marginal convenience - Don't assume a full-page SSR reload is automatically worse UX than a SPA — HTMX-style fragment swaps get most of the responsiveness without a JS framework
Interview Questions
Q: Why can't you trust data or validation that happens on the client?
Anything running in the browser is fully visible and modifiable by the
user — through browser DevTools, by editing the JavaScript, or by
skipping the browser entirely and calling the API directly with a tool
like curl. Client-side checks only run if the client chooses to run
them, so they can only ever provide user experience, never enforcement.
Q: What's the difference between Server-Side Rendering and Client-Side Rendering?
In SSR, the server builds the complete HTML for a page and sends it
ready to display — the browser needs no JavaScript to see the content.
In CSR, the server sends a minimal HTML shell plus JavaScript, and that
JavaScript fetches data and builds the page inside the browser after
it loads.
Q: Give an example of something that must run on the server and explain why.
Password hashing. It requires a secret-free but sensitive operation
(comparing against a stored hash) that must happen somewhere the user
cannot observe or bypass the comparison logic, and it typically needs
direct database access the client is never given.
Q: A login form stores the JWT it receives with localStorage.setItem('token', token). What's wrong with this, and what would you do instead?
localStorage is fully readable by any JavaScript executing
on the page. If the application has an XSS vulnerability anywhere —
including in a third-party script or a dependency — that script can
read the token and exfiltrate it, giving an attacker a fully valid
session with no further effort. The fix is to have the server set the
token as an HttpOnly cookie: JavaScript can never read it,
even under active XSS. This does introduce CSRF exposure (the browser
will attach the cookie to cross-site requests automatically), which is
mitigated by pairing HttpOnly with
SameSite=Strict or Lax, and, for
state-changing requests, a CSRF token as defense in depth.
Q: Why is "the client validated the price, so the server just needs to persist it" a critical design flaw rather than a minor oversight?
It inverts the trust boundary the entire architecture depends on. The
server is the only party with an unforgeable view of the product
catalog, current stock, and active promotions — it is the sole source
of truth. Accepting a computed value (a total, a discount, an
eligibility flag) from the client means the server has delegated a
trust decision to an environment it doesn't control. The correct
pattern is for the server to accept only raw identifiers from the
client (product ID, quantity, coupon code) and independently recompute
anything that affects money, permissions, or state changes.
Q: When would streaming SSR or React Server Components be a better fit than a plain SPA with a JSON API, and what does that choice cost you?
They fit content-heavy, SEO-sensitive applications (storefronts,
marketing sites, content platforms) where first paint and crawlability
matter more than a fully client-driven interaction model. Server
Components let you fetch data and render close to it without shipping
that code or its dependencies to the browser, which reduces bundle
size. The cost is architectural: your rendering pipeline now spans
server and client with two different execution models to reason about,
your Java backend team and frontend team need a clear contract for
which components render where, and debugging a rendering issue
requires knowing which side of the boundary it happened on before you
can even start.