Open pub-trivia.app/tools/qr-code-generator, then paste this into the console:
const js = () => performance.getEntriesByType("resource").filter(r => /\.js(\?|$)/.test(r.name));
const total = () => ({ files: js().length, bytes: js().reduce((a, r) => a + r.encodedBodySize, 0) });
total()
On a cold load I get { files: 18, bytes: 362945 }. Now type an address, press Generate code, and run total() again:
{ files: 20, bytes: 379536 }
Two new files, 16,591 bytes. Then press A4 sheet of four and run it a third time:
{ files: 22, bytes: 516611 }
Another 137,075 bytes, which decompresses to 419 KB. That is jspdf, and it arrives on the click or it does not arrive at all.
Four tools, no backend, two large dependencies
The tools cluster is four pages: a QR code generator, a scoresheet generator, a round planner and a team name generator. None of them has a route handler behind it. They exist because people search for them, and they are the kind of page where the honest implementation and the cheap implementation happen to be the same one.
Two of the four need real libraries, though. qrcode to draw the code, and jspdf to lay out an A4 sheet. Both are imported inside the function that needs them:
const generate = async () => {
const target = normalise(url)
if (!target) { setError("That does not look like a web address."); return }
setBusy(true)
try {
const QRCode = (await import("qrcode")).default
setDataUrl(await QRCode.toDataURL(target, { width: 512, margin: 2 }))
} catch {
setError("Could not generate a code for that address.")
} finally {
setBusy(false)
}
}
const downloadSheet = async () => {
if (!dataUrl) return
setBusy(true)
try {
const { jsPDF } = await import("jspdf")
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" })
// ... four codes on A4, 15 mm margins
doc.save(`${slug}-table-cards.pdf`)
} finally {
setBusy(false)
}
}
The arithmetic for doing it the other way: a static import { jsPDF } from "jspdf" at the top of the component puts 137 KB into the page's first load, on a page that lives in a content cluster and is reached overwhelmingly from search. Most visitors to a page titled "free scoresheet generator" read the page, decide whether it does what they want, and leave. Charging every one of them 137 KB for a button a minority press is the wrong default, and it is 137 KB that competes with the content for the same few hundred milliseconds.
There is a real cost to the lazy version, which is that the first press has network latency in it. That is what setBusy(true) is for, and it is the only reason these handlers have a busy state at all: without the dynamic import there would be nothing to wait for.
The better reason, which is that nothing is uploaded
Bundle size is the engineering argument. The argument that actually matters on these pages is on the page itself: the address you type is never sent anywhere.
That is not a promise, it is a consequence. The data URL comes out of QRCode.toDataURL() in your tab, the PDF comes out of jsPDF in your tab, and there is no endpoint to post to. A hosted QR generator is a tempting thing to run badly: the easiest way to build one is to generate server side, and the most profitable way to build one is to encode a redirect through your own domain so you can count the scans. Our dashboard needs codes for table cards anyway, qrcode has a browser build, so the tool is the same dependency with no server in the path.
You can check the claim rather than taking it:
- Open the Network tab on the tool page, filter to Fetch/XHR, and clear it.
- Type an address and generate a code.
- The only new entries are script chunks. No request body contains what you typed.
Being precise about what the page does request, since "nothing leaves your browser" is usually an overstatement: there are route prefetches for the links in the header and footer, and an error monitoring beacon, both to our own origin, both present whether or not you touch the form. What there is not is a request carrying the input.
The other two tools need no import at all. The team name generator picks from a 110 name module across five categories, which is a 5.9 KB TypeScript file rendered into the HTML on the server so the page has something a crawler can read. The round planner is useMemo over some numbers. Neither one needed a decision.
Two small guards worth copying
new URL("example.com") throws, because a bare domain is not a URL. A bare domain is also exactly what people type, so rejecting it would be rejecting the common case:
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try { return new URL(candidate).toString() } catch { return null }
And in the scoresheet generator, every numeric input is clamped on the way into state rather than validated on submit:
const LIMITS = {
rounds: { min: 1, max: 12 },
questionsPerRound: { min: 1, max: 20 },
teams: { min: 1, max: 40 },
}
The comment next to it says what it is for: so a typo cannot ask for a 900 page PDF. When generation happens on the visitor's own machine, an unbounded input is not a server problem you will see in a dashboard, it is their laptop fan, and they will blame the page rather than the number they typed.
All four tools are at pub-trivia.app/tools, free, no sign in, nothing stored. The app they are advertising is at pub-trivia.app, which does have an account, and a free tier that does not ask for a card.
Top comments (0)