DEV Community

Sungwoo Lee
Sungwoo Lee

Posted on

65 Calculators, One Cloudflare Worker, No Build Step

I run a small content site that also hosts 65 calculator pages — salary after tax, loan interest, character count, percentage, a grade calculator, and 43 math/exam-prep tools. All of them live in a single Cloudflare Worker. There is no framework, no bundler, no client-side JS payload beyond the few hundred bytes each page needs to do its own arithmetic.

This post is about why that setup is a good fit for this particular problem, and where it stops being one.

The shape of the problem

A calculator page is unusual among web pages: the interesting part is tiny and the boring part is everything else.

The interesting part is a pure function. Given a salary, a number of dependents, and a non-taxable allowance, return six deduction amounts. That's maybe 40 lines. The boring part — page shell, navigation, meta tags, structured data, FAQ markup, related-tool cards, the privacy note explaining that nothing is sent to a server — is the same on all 65 pages and dwarfs the calculation.

So the architecture question isn't "how do I build a calculator." It's "how do I stamp out 65 near-identical documents where only a small slot differs."

What the Worker actually does

Every request hits one Worker. It matches the path, looks the tool up in a flat array, and returns a string.

const TOOL_LIST = [
  { path: '/tools/char-count', name: 'Character counter',
    desc: 'Counts with and without spaces, bytes, manuscript pages', g: 'everyday' },
  { path: '/tools/salary', name: 'Take-home pay calculator',
    desc: 'Monthly net after insurance and income tax', g: 'work' },
  // ...
];
Enter fullscreen mode Exit fullscreen mode

That array is the single source of truth. The tools index page, the nav, the related-tool cards on article pages, and the sitemap all read from it. When I added the take-home pay calculator, I added one row and it appeared in four places without any of them being edited.

The page-specific script is stored as an array of source lines and joined at render time:

const SAL_SCRIPT = [
  "const n = (id) => Number(document.getElementById(id).value || 0);",
  "function calc() { /* ... */ }",
  // ...
].join(String.fromCharCode(10));
Enter fullscreen mode Exit fullscreen mode

The String.fromCharCode(10) looks silly next to '\n'. It's there because this source string travels through several layers of tooling, and a literal backslash-n has been folded on me more than once. Using the char code makes the newline immune to whatever escapes the string on its way through.

Why the calculation runs in the browser

Every one of these tools computes client-side. Nothing about the input is transmitted.

Partly that's a privacy claim I want to be able to make honestly: people paste cover letters into a character counter, and I would rather not have that text touch my logs even accidentally. Partly it's just cheaper — a Worker invocation that returns a static string and never awaits anything is about as cheap as a request gets.

The tradeoff is that the logic ships to the client, so it's readable and copyable. For a tax table encoding that's a wash; the numbers are published by the tax authority anyway.

The one place this actually got hard

The income tax portion isn't a formula. It's a lookup table: 646 income brackets × 11 dependent counts, published as a spreadsheet.

Shipping that as JSON was 400 KB. Instead I encoded it as a run-length-ish delta string and unpacked it in about 15 lines at page load. The encoded module is 31 KB. The unpack function is small enough to read in one sitting, which mattered more to me than squeezing the last few KB, because the failure mode of a clever encoding is silently wrong money.

I verify it by running the exact page script — pulled out of the source file at test time, not a copy — against known values:

const src = readFileSync('tools_pay.js', 'utf-8');
const m = src.match(/const SAL_SCRIPT = \[([\s\S]*?)\]\.join\(/);
const script = new Function('return [' + m[1] + '].join(String.fromCharCode(10));')();
Enter fullscreen mode Exit fullscreen mode

Pulling the literal out of the real file rather than importing a copy is the whole point. A copy drifts, and then your tests pass against a version nobody ships.

Where this approach stops working

Three honest limits:

No component reuse across pages beyond string concatenation. When two tools want the same input widget with slightly different labels, you write a function that returns a string with parameters. That's fine at 65 pages. I would not want to find out where it stops being fine.

No client-side routing, and I don't want any. Each tool is a separate document with its own title, description, and structured data. That's deliberate — these pages exist to be found by search, and a single-page app would collapse 65 search targets into one.

Debugging inline script is worse than debugging a module. Source maps don't help you when the source is a joined array. In practice I develop the logic as a normal file and only move it into the array form when it settles, which is a smell I've made peace with.

What I'd tell someone starting this

If your pages are mostly identical shells around a small pure function, and if being individually indexable matters more than sharing state between pages, a single Worker plus a flat config array will take you further than it sounds like it should. The whole surface is 65 URLs, and the deploy is one command.

The part that actually determined whether anyone found these pages turned out to have nothing to do with any of this — it was internal linking, and I got it badly wrong for months. That's a separate post.

The full set is at my-blog.org/tools if you want to poke at the output. Most of it is in Korean, but view-source works in every language.

Top comments (0)