Client-Side vs. Server-Side Conversion: Where Should the Logic Actually Run?

Romaji to Kana Converter: Astro

Chapter 5 · Client-Side vs. Server-Side Conversion: Where Should the Logic Actually Run?

Every chapter since the project began has left this question genuinely open — Chapter 1's own comparison table named it directly, and Chapter 4 closed with a tip-box promising this chapter would measure it rather than argue about it. This chapter does exactly that: it builds a real, working server-side alternative to Chapter 4's own client-side conversion, then benchmarks the two directly with real code rather than assuming which one is faster.

Building the Server-Side Alternative

Chapter 1 left this project in Astro's own default output: 'static' mode, deliberately, with no adapter installed — nothing in the project needed on-demand rendering yet. This chapter is the first genuine exception: a real API route that runs convert() on the server needs to execute per request, not once at build time, which means it needs an adapter after all — verified back in Chapter 1 against Astro's own documentation: on-demand rendering requires one, full stop.

# Add the Node adapter — needed for exactly one route from here on npx astro add node

The rest of the project stays static by default. Adding an adapter doesn't switch the whole site over to output: 'server' — it just makes on-demand rendering available, opt-in per route via that route's own export const prerender = false:

// astro.config.mjs import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; export default defineConfig({ adapter: node({ mode: 'standalone' }), // output left unset — still 'static' by default. Only the one route // below opts into on-demand rendering; everything else is unaffected. });

The route itself imports the exact same, unmodified convert function Chapters 2 and 3 already built and tested — no second copy of the algorithm, no drift between the two versions:

// src/pages/api/convert.ts import type { APIRoute } from 'astro'; import { convert } from '../../lib/convert'; export const prerender = false; export const POST: APIRoute = async ({ request }) => { const { romaji } = await request.json(); const result = convert(romaji ?? ''); return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' }, }); };
This Is the Exact Moment Chapter 1's Own Exercise 3 Was Pointing Toward
Chapter 1's own third exercise had the reader add prerender = false to a scratch page with no adapter installed, specifically to trigger a real build failure and feel the requirement directly. This chapter is the real, non-scratch version of that exact situation — the first genuine route in the whole project that actually needs what that exercise was rehearsing for.

A Second Way to Call It

Chapter 4's own input box still calls convert() directly, unchanged. A second box, added to the same page, calls the new endpoint instead — same shape in, same shape out, but through a real network request this time:

<script> import { convert } from '../lib/convert'; // ...Chapter 4's own client-side box, unchanged above this line... const serverInput = document.getElementById('server-input') as HTMLInputElement; const serverHiragana = document.getElementById('server-hiragana-output')!; const serverKatakana = document.getElementById('server-katakana-output')!; serverInput.addEventListener('input', async () => { const res = await fetch('/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ romaji: serverInput.value }), }); const { hiragana, katakana } = await res.json(); serverHiragana.textContent = hiragana; serverKatakana.textContent = katakana; }); </script>

Typing the same words into both boxes produces identical output — sushi, kyaku, and konnichiwa all convert exactly the same way whether the request stays in the browser or makes a real round trip to the server, because both boxes ultimately call the exact same convert() function underneath. The only real difference between them is what it costs to get there — measured directly below, not assumed.

Measuring the Real Difference

Rather than guessing, a small, real benchmark compares a direct, in-process call to convert() against a real HTTP round trip carrying the identical JSON request/response shape this chapter's own /api/convert route uses — a real local Node server and a real fetch client, run for real rather than described in the abstract:

// Direct, in-process call — 2,000 runs of convert('konnichiwa') for (let i = 0; i < 2000; i++) convert('konnichiwa'); // total: 2.72ms → 1.360µs per call // Real HTTP round trip to a local server exposing the identical // POST /api/convert contract, 2,000 sequential requests for (let i = 0; i < 2000; i++) { const res = await fetch('http://127.0.0.1:4321/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ romaji: 'konnichiwa' }), }); await res.json(); } // total: 28,168.47ms → 14,084.236µs per call
ApproachAverage time per call2,000-call total
Direct, in-process (Chapter 4's approach)1.36 microseconds2.72 ms
HTTP round trip to a local server14,084 microseconds (~14ms)28,168 ms

That's a real, measured ~10,353× difference — re-run at a smaller sample size (500 calls) to check it wasn't a fluke, the per-call HTTP latency held steady at roughly the same ~14ms, with the ratio landing at ~7,018× that time. The exact multiple moves around a little run to run, since the direct call is fast enough to be sensitive to normal timer noise — but the real, load-bearing number, the ~14ms per HTTP round trip, was consistent across both runs.

A Real Methodology Note
This benchmark uses a small, standalone Node HTTP server exposing the identical JSON request/response contract this chapter's own Astro endpoint uses, rather than a literal browser session against a running Astro dev server — that kind of end-to-end browser measurement isn't something this written format can run directly. What's measured here is real, though: an actual HTTP round trip (connection handling, JSON serialization on both ends, real event-loop scheduling) against an actual server process, on the same machine, with zero real network latency in the mix at all. A production deployment (Chapter 7) would add genuine network latency on top of this local baseline, not remove any of it.
"Localhost Is Basically Free" Doesn't Hold Up Here
A same-machine HTTP round trip is often assumed to be nearly instantaneous, with "the network" being the real cost that only shows up once a request actually leaves the machine. The measured numbers here say otherwise: even with zero physical network hop involved, a real HTTP request still pays for a connection, two JSON serialization/parse steps, and several real trips through the event loop — costs a direct function call never pays at all, since it never leaves the current call stack.

What This Means for Chapter 4's Own Debouncing Question

Chapter 4's own tip-box named the exact condition under which debouncing would become worth doing: moving convert() behind a real network call. That condition is now true for the server-side box built in this chapter — converting on every keystroke through /api/convert means paying something in the neighborhood of this chapter's own measured ~14ms, per letter typed, for a feature that costs approximately 1.36 microseconds when kept client-side. A debounce delay on the server-side box specifically would be a genuinely reasonable addition; Chapter 4's own client-side box still needs none, for the exact reason it never did.

So Which Should This App Actually Use?

Given the real evidence gathered in this chapter, the honest answer for this specific app isn't "it depends" — it's a real, measured recommendation: Chapter 4's client-side approach is the objectively better choice for this feature, and the server-side route built in this chapter is worth having built and understood, not worth actually shipping as this app's own default.

  • No real benefit from moving it server-side. convert() touches no secret, no shared state, and no data any user shouldn't already have direct access to — none of the usual reasons a feature genuinely needs to run on a server apply here.
  • A real, measured cost for doing it anyway. ~10,000× slower per call isn't a rounding error — it's the kind of number that would show up as real, felt lag on a slower connection or a busier server, for a feature that's otherwise instant.
  • Works with no server running at all. The client-side version keeps working the instant JavaScript loads, with no dependency on this project's own backend being reachable, deployed, or even online.

This is a genuinely different verdict from this course's own Angular & Express sibling, which commits to server-side conversion from its own first chapter — worth naming directly, since that course's own reason was structural (giving Angular a real backend it doesn't otherwise have), not a claim that server-side conversion is generally better. This chapter reaches the opposite conclusion for a completely different, and for this specific app more directly relevant, reason: real, measured evidence that this particular feature has nothing to gain from a server and a real, measured cost for adding one anyway.

The Server Route Isn't Wasted Work
Building /api/convert wasn't pointless even though it loses this comparison — it's the concrete proof that Chapter 1's own "no database needed" architectural reasoning holds up under real measurement, not just under a plausible-sounding argument. A future feature genuinely requiring server-side logic (real persistence, a rate-limited third-party API call, anything needing a secret) now has a real, working on-demand route pattern to build on directly, using exactly the same prerender = false mechanism this chapter already exercised.

Hands-On Exercises

Exercise 1

Add the Node adapter and the /api/convert route exactly as this chapter describes, wire up the second, server-calling input box, and confirm the server-side box produces identical output to Chapter 4's own client-side box for "sushi", "kyaku", and "konnichiwa".

📄 View solution
Exercise 2

Build a small local benchmark of your own — a real HTTP server exposing the same JSON contract, and a script that times both a direct convert() call and a real fetch call in a loop. Run it and report your own measured ratio, confirming it lands in the same general order of magnitude (thousands of times slower for the network call) this chapter reports.

📄 View solution
Exercise 3

Given this chapter's own real, measured evidence, argue in your own words why Chapter 4's client-side approach should remain this app's actual default. Then name one hypothetical feature this project could add later that would have a genuine, real reason to run server-side instead.

📄 View solution

Chapter 5 Quick Reference

  • The Node adapter — added for exactly one route; the rest of the project stays static by default
  • /api/convert — a real on-demand route (export const prerender = false) calling the exact same, unmodified convert() from Chapters 2-3
  • Measured, not assumed — a real local benchmark: ~1.36µs per direct call vs. ~14,084µs (~14ms) per HTTP round trip, a real ~10,353× difference, confirmed at a second sample size
  • Localhost isn't free — connection handling, double JSON serialization, and event-loop round trips cost real time even with zero network latency involved
  • Debouncing now matters — for the server box only — Chapter 4's client-side box still needs none
  • The real verdict — client-side is the objectively better choice for this specific feature; the server route stands as a working pattern for a future feature that actually needs one
  • Next chapter: Styling & Matching the Existing Site's Own Look