Architecture spec · framework-free · no jargon required
A website that shows comics but stores none of them
The page you're reading is tiny. The artwork it serves — hundreds of thousands of images — lives somewhere else entirely, spread across 99 separate delivery networks. What decides where each image goes? A short piece of math any computer can run and every computer agrees on. That one idea is the whole trick, and this page explains it from the ground up.
The app serves 0 images and runs 0 servers — every picture lives out on the shards.
01 The big idea
If you remember one thing, make it this section.
Imagine a library so enormous that no single building could hold it. So instead you build 99 small branches, and you agree on one rule: given any book's title, anyone can do a quick calculation on the title alone and know exactly which branch holds it. No central desk, no phone calls — just the rule, which everyone runs and everyone lands on the same answer.
That's this whole system. The "books" are comic pages. The "branches" are 99 ordinary, free delivery networks. The "rule" is a small function called a hash — think of it as a blender that turns a name into a number. The clever part: the same rule runs once when a picture is filed away, and again later in your browser when it wants that picture back — so the web address of an image is worked out on the spot, never stored in a list.
chapter 5, page 3 runs through the rule and comes out as shard 54. Same math, every time — no list to look up.Try it yourself
Pick a page. Watch the rule drop it into one of the 99 shards. Change the page and it lands somewhere else — this is the exact math the real site runs.
The name it builds: myseries/chapter-005/003.webp
Notice: nearby pages scatter to completely different shards. That's the point — the work spreads evenly, so no single shard carries the load.
That's the entire trick. Everything else on this page — the scraper, the optimizer, the deploy scripts — exists only to feed this one rule and keep both sides of it in perfect agreement.
02 Why it's built this way
Nine decisions, each with the reason it beat the obvious alternative.
Every system is a pile of trade-offs. Here are the ones that shaped this one — in order, and in plain terms first.
D1 · A static app, with no server behind it
The interface is built ahead of time into plain files. There's no server to run, patch, or pay for; it's hosted free and cached everywhere. The catch: anything that would normally need a database — like each image's size — has to be figured out at build time and baked in. That's what pageMeta (below) solves.
D2 · 99 shards + 1 app = the 100-project limit
Free hosting plans usually cap how many projects one account can have — often around 100 — not how many visitors you get. So the design spends that budget on purpose: 99 projects hold images, 1 holds the app. Ninety-nine isn't magic; it's simply "the limit, minus the app." Use your platform's limit minus one.
D3 · One rule, shared as a single file
The rule that picks a shard lives in one small file that both the filing tool and your browser use. Because they run the identical code, they always agree. This file is a contract: if the two copies ever drift apart, images vanish (they get filed in one shard and looked for in another). The filing tool double-checks a known example on every run to catch that instantly.
D4 · A tidy naming scheme for every file
Every image has exactly one official name: <comic>/chapter-<NNN>/<file>.
Putting the comic's name in front means each comic's pages also scatter across all 99 shards, so the whole
fleet fills evenly instead of one shard per comic. That name is the only thing the rule looks at.
D5 · How the rule turns a number into a shard
The rule produces a big number; that number is sliced into 99 equal bands, and whichever band it falls in is the shard. Even bands + an even spread of numbers means an even spread of files, automatically. It's instant and needs no memory. (For the curious, three other valid methods are kept in the code as documented options.)
| Method | Cost | Even? | Use when |
|---|---|---|---|
Remainder (number % 99) | instant, no memory | Yes | Simplest; fixed shard count. |
| Equal bands (chosen) | instant, no memory | Yes, by design | Fixed count + want orderability. |
| Jump hash | tiny, no memory | Yes, any count | Count might change later. |
| Hash ring | some memory | Yes | Count changes often. |
D6 · Why the "blender" needs two stages
A simple name-to-number hash is fast but lumpy — similar names can clump onto the same shard. So a second scrambling stage is applied that mixes the bits thoroughly, giving an even spread even for near-identical names. It's not for secrecy — the chosen shard is visible in every image's address anyway. It's purely for a fair, even split.
D7 · Sizes are measured once and baked in
To lay out a page without it jumping around as images load, the browser needs each image's dimensions up front. A static app can't ask a database, so the optimizer writes a small file per comic listing every page's width and height. The app reads it at build time. One file per comic, so building one comic never disturbs another.
D8 · A modern format, with one hard limit
Images are converted to WebP (small, widely supported). WebP has a ceiling of 16,383 pixels on a side. Very tall scroll-style pages can blow past it, so the optimizer slices those into stacked panels first. Sources that already ship small, pre-cut pages skip this step.
D9 · Even the cover art rides the same rails
A comic's cover is treated exactly like a page — one more file, placed on a shard by the same rule, fetched by the same address logic. The app never links to an outside image at all. Cover art is fetched once, at build time, and turned into a shard file like everything else.
03 The rule that picks a shard
For the technically curious: the exact code, and why every character matters.
You saw it in action up in the playground. Here's the actual rule — small enough to read in a minute.
One file exports the mapping. Both the filing tool (in Node) and your browser import it. Reproduce it
faithfully — the exact constants and the >>> 0 (which forces a plain 32-bit number)
matter, or the two sides disagree.
// hash.mjs — imported by BOTH the filing tool (Node) and the browser.
// Uses only charCodeAt + Math.imul so the result is identical everywhere.
export const NODES = 99; // shard count = the platform's project limit − 1
export function hash32(str) {
let h = 0x811c9dc5; // starting value
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193); // mix in each letter
}
h ^= h >>> 16; // ── the second, scrambling stage ──
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0; // force a plain 32-bit number — REQUIRED
}
// slice the number into 99 equal bands → a shard from 0 to 98
export const nodeFor = (key) => Math.floor((hash32(key) * NODES) / 4294967296);
// the official name — the ONLY thing the rule looks at
export const keyFor = (comic, chapterDir, file) => `${comic}/${chapterDir}/${file}`;
The browser turns a name into an address by adding 1 (shards are numbered from 1, the code counts from 0):
// in the browser — work out the address, never store it
function imageUrl(comic, chapterDir, file) {
const key = keyFor(comic, chapterDir, file);
const nnn = String(nodeFor(key) + 1).padStart(3, '0'); // "001".."099"
return `https://cdn-node-${nnn}./${key}` ;
}
name → shard example on every run.
04 How pages get in
Four tidy steps take a raw page all the way to the shards.
Getting a comic online is an assembly line: gather → tidy up → file away → publish. Each step does one job and hands a clean result to the next.
Step 1 — gather
The least reusable step, because every source is different (and sources are out of scope here). What does carry over is the discipline: list the chapters, then the pages; trust the bytes, not the labels (a "blocked" page can pretend to be an image); rebuild each chapter cleanly; and be polite (small delays, back off when asked).
// the one bit worth copying verbatim: is this actually an image?
const isWebp = (b) =>
b.length > 100 &&
b.slice(0, 4).toString('latin1') === 'RIFF' &&
b.slice(8, 12).toString('latin1') === 'WEBP'; // JPEG starts FF D8 · PNG starts 89 50 4E 47
Step 2 — tidy up
// convert + measure; slice only if a side would exceed WebP's limit
for (const page of pages) {
const { width, height } = await probe(page);
if (height > 16383) sliceIntoPanels(page, 16000); // stacked, under the ceiling
else encodeWebp(page, { quality: 80 });
sizes[chapter].push([width, height]); // remembered for layout
}
writeJson(`pageMeta/${comic}.json`, sizes); // this comic's file ONLY
Step 3 — file away
const check = nodeFor(keyFor(comic, 'chapter-005', '003.webp')) + 1;
if (check !== KNOWN_SHARD) throw new Error('the rule drifted!'); // fail loudly, early
for (const [chapterDir, file] of allFiles) {
const nnn = String(nodeFor(keyFor(comic, chapterDir, file)) + 1).padStart(3, '0');
copyInto(`cdn-node-${nnn}/${comic}/${chapterDir}/${file}`);
}
// then report how many files landed in each shard — should be a tight, even spread
05 The 99 shards, up close
Ninety-nine near-identical mini-sites, all set up by script.
A shard isn't anything fancy — it's a plain folder of images served at its own web address. The only challenge is that there are 99 of them, so nothing is done by hand.
| Job | How it's handled |
|---|---|
| Create the projects | Loop 1→99; make each one if it's missing, skip it if it exists. |
| Per-project settings | One template; a placeholder (like XXX) is swapped for the 3-digit number as it's copied in. |
| Publish | Loop 1→99; push each shard's folder to its project. The app publishes separately. |
| Cross-origin access | Shards live on different addresses than the app, so each must allow the app to read its images. |
| Caching | Names rarely change, so images can be cached hard at the edge. |
Developing without standing up 99 real sites
Creating 99 live projects just to develop would be wasteful, so the app has a switch (one setting) that only changes where image addresses point:
| Mode | Address it builds | For |
|---|---|---|
dir | /img/<comic>/<chapter>/<file> | Your own local folder — no sharding at all. |
local | /cdn-node-<NNN>/<name> | Sharded, but all on one address for testing. |
pages | https://cdn-node-<NNN>.<domain>/<name> | The real 99-network fleet, live. |
The rule is identical in every mode — only the address in front changes.
06 How it grew
Each stage shipped and worked before the next one started.
-
Stage 0 · Prove it
One comic, one plain origin
Build the reader against a single image source. Nail the reading experience first — before any sharding exists.
-
Stage 1 · Spread it
Across the 99-shard fleet
Introduce the shared rule and the filing tool. Swap the single source for the worked-out shard address. This is where the magic turns on.
-
Stage 2 · Multiply it
Many comics, same fleet
Put the comic's name into the rule so every comic co-exists on the same 99 shards. One list of comics drives the whole catalog.
-
Stage 3 · Own everything
Covers become shard files too
Stop linking to any outside image. Cover art is fetched once and placed on a shard like every page.
-
Stage 4 · Polish
Catalog niceties
Status badges, real descriptions, offline downloads, bookmarks — all static, all from that one list.
-
Stage 5 · Explain
This page
Write the idea down so the next person can rebuild it from scratch.
-
Someday
Where it could go
A smarter rule so the shard count can grow past the free limit; a signing step for integrity. All optional — the core stays a single small rule.
07 Build it yourself
In order. Each step is something you can check before moving on.
- Get set up. Install Node and git. Sign up for a static host that offers lots of free projects with their own web addresses. Pick an image-processing library. Find your platform's project limit — that number minus one is your shard count.
-
Decide the naming scheme. Settle on the one official name for every file
(
<comic>/chapter-<NNN>/<file>) and write it down. It's the rule's only input; never let two parts of your code format it differently. - Write the rule — the contract. Copy the function from §3 exactly. Check: print a shard number for a test name and confirm it's steady across runs.
- Prove it spreads evenly. Feed it 100,000 made-up names and count how many land in each shard; the counts should be close. If one shard is lopsided, the scrambling stage or the 32-bit step is wrong. Fix this before anything else — everything rests on it.
- Build the reader against fake images. A static site that, given a name, builds its address from the rule. Start with a handful of local test images and get the page rendering with no jumping.
- Write "gather." Pull source pages into local folders. Trust the bytes, back off politely, rebuild each chapter cleanly. Check: pages are numbered with no gaps, none empty.
- Write "tidy up." Convert to WebP, slice anything too tall, measure every size, and write the sizes file. Check: the counts match what's on disk.
- Write "file away." Import the same rule file. Assert a known name→shard example. Copy every file into its shard folder. Check: the total copied matches disk, and the spread is even.
- Set up the fleet. Script the creation of your shards + 1 app project, looping and idempotent, with cross-origin access turned on. Keep every credential out of the code.
- Publish, then flip the switch. Push each shard folder and the app. Point the reader at the real fleet. Check: pick a few names, work out their shard by hand, and confirm the live address returns a real image.
- Add a second comic. Repeat gather → tidy → file for another comic. Because the comic's name is in the rule, its files weave across the same 99 shards. If it just works with no changes to the fleet — you're done.
Glossary
Every term on this page, in plain words.
- Shard (or bucket / node)
- One of the 99 mini-sites that each hold a slice of the images. "Node" is the number the rule returns, counting from 0; "shard" usually means the folder or project, counting from 1. Node + 1 = shard.
- Hash
- A small function that turns a name into a number — like a blender for text. Same name in, same number out, every time.
- Key / name
keyFor(...) - The one official name of a file:
<comic>/chapter-<NNN>/<file>. The only thing the rule looks at. - The contract
hash.mjs - The single shared file holding the rule, used by both the filing tool and the browser. If the two copies differ, images vanish.
- Even spread
- The goal that every shard holds roughly the same number of files. The scrambling stage of the hash is what makes it happen.
- pageMeta
- A small file per comic listing every page's width and height. Baked into the app so the page never jumps around as images load.
- Layout jump (shift)
- Content lurching as images load because their space wasn't reserved. Avoided by knowing sizes up front.
- Magic bytes
- The tell-tale first few bytes of a file that reveal its true type. Checking these — instead of trusting a label — catches a "blocked" page pretending to be an image.
- WebP limit
- WebP images can't exceed 16,383 pixels on a side. Taller pages get sliced into stacked panels first.
- Slicing
- Cutting one very tall image into several full-width panels, shown stacked so the joins are invisible.
- Idempotent
- A fancy word for "safe to run again." Re-running gives the same result with no duplicates or mess — what makes a giant import resumable.
- Project limit
- The cap on how many projects one free account can have (often ~100). It sets the shard count: limit − 1 (one is saved for the app).
- Static app
- A website that's pre-built into plain files, with no server or database behind it. Anything dynamic-looking is baked in ahead of time.
- Edge / CDN
- A delivery network that keeps copies of files in many places worldwide, so visitors get them from somewhere nearby and fast.