Server vs Client Execution

Where code runs determines what you can trust, what you can secure, and what you can never take back once it's shipped to a browser

← Back to Index

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

TaskRuns onWhy
Database accessServerThe client has no network path to your database and must never be given one
Password hashing, token generationServerSecrets and cryptographic keys cannot exist in code you ship to a browser
Authorization checks ("can this user do this?")ServerA check the client can skip is not a check
Business rules (pricing, eligibility, limits)ServerMust be enforced identically for every client, and be unbypassable
UI rendering, DOM updatesClientThe user's screen is, by definition, in the user's browser
Form validation for user feedbackClient (in addition to server)Instant feedback with no network round-trip — but never a substitute for server validation
Animations, transitionsClientZero 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

Everything the browser sends can be forged

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."
Where you store a token matters as much as how you generate it

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.
AspectSSRCSR
Initial paintFast — HTML arrives readySlower — blank page until JS loads and fetches data
SEOContent visible to crawlers by defaultRequires prerendering or a crawler that executes JS
Navigation after first loadFull page reload per navigation (unless paired with HTMX/Turbo)Instant, no reload (SPA)
Server loadHigher — renders markup per requestLower — server only serves JSON
Java toolingThymeleaf, JSPNone — 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>
Where React Server Components and streaming SSR fit

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 HttpOnly cookies when the client is a browser, not in localStorage
  • 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 localStorage if an HttpOnly cookie 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

🎓 Junior level

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.

🔥 Senior level

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.