Skip to content

Scaling Longbox

Longbox runs on one server on a home network. This document works out what it would take to run it as a service for people scattered around the world, and — more usefully — which of those decisions have to be made now, before the reader is built, versus which can wait until there is a reason.

The short version: the reader is the whole problem, the fix is to keep the application out of the byte path, and that decision has to be made before the reader is written rather than after.


1. What actually breaks

The reader, and almost nothing else

A comic page is roughly 0.5–2 MB. A thirty-page issue is 20–60 MB. Someone reading three issues in an evening pulls down more data than the entire rest of the application will transfer in a year.

That means reader performance is a bandwidth and latency problem, not a code problem. No amount of tuning the Python makes a 40 MB issue arrive quickly in Frankfurt from a server in a spare room in Australia. The only fix is to serve the bytes from somewhere near the reader.

Today the application is squarely in the byte path:

@app.get("/comic/{comic_id}/page/{index}")
def comic_page(comic_id, index, user = Depends(require_user)):
    with archives.open_comic(comic["path"]) as handle:
        data, content_type = handle.page_bytes(index)
    return Response(data, media_type=content_type)

Every page turn opens the archive, decompresses one entry — or, for a PDF, renders a page — and streams it through the web process. Nothing is cached, so turning back a page does the work again. On a home server for one household that is completely fine. As a service it is the bottleneck, the CPU bill and the egress bill all at once.

Storage is a local path

comic.path is an absolute filesystem path, the importer walks the local filesystem, and archives.open_comic() assumes the file is reachable by the process. Nothing can move to object storage without touching all three.

State lives inside the process

Three things assume there is exactly one application instance:

What Where Consequence
Import job queue An in-process queue.Queue A second instance has its own queue and cannot see the first one's work
Job claiming UPDATE import_job SET status='running' WHERE id=? Unconditional, so two workers would both claim the same job
Failed sign-in throttle A module-level dict Per-process, so N instances give an attacker N× the attempts, and round-robin defeats it entirely

None of these is broken today, because there is exactly one process. All three prevent there ever being two.

SQLite

One writer, one file, one machine. Worth being precise about this, because it is usually over-stated: SQLite in WAL mode handles a read-heavy workload at a scale that would embarrass most people's assumptions, and Longbox is read-heavy. It is not the first thing to break. What it cannot do is span machines or fail over.


2. The target shape

The architecture every media service converges on is a control plane / data plane split:

                    ┌──────────────────────────────────────┐
   small JSON       │  control plane                       │
   ──────────────►  │  auth, metadata, entitlement         │
                    │  Longbox app + database              │
   Browser          └──────────────────────────────────────┘
      │                              │
      │                              │ issues a short-lived signed URL
      │                              ▼
      │             ┌──────────────────────────────────────┐
      └───────────► │  data plane                          │
   big images       │  CDN edge  →  object storage         │
                    └──────────────────────────────────────┘

The application answers "may this person read page 14, and where is it?" and returns a URL. It never carries the bytes. The CDN caches at the edge, so the second person to read that issue in Europe gets it from Europe.

Two consequences worth stating plainly:

  • Pages become derived artefacts, not computed responses. Page 14 of a comic is extracted once, stored, and served many times. It is keyed by (comic uuid, page number, rendition) — rendition being size and format, so a phone can be sent something smaller than a desktop.
  • Entitlement is checked when the URL is issued, not when the bytes are served. That is what makes CDN caching possible at all, and it is why signed URLs need short lifetimes.

3. What to build now, and what not to

Ordered by value today × cost of retrofitting later. The honest summary is that only one of these genuinely has to happen before the reader.

A. Pages as derived assets — decide now, build with the reader

This is the decision that cannot be deferred, because it determines the shape of the reader itself.

The reader must be written against "give me the URL for page 14", never "stream me page 14". Those produce completely different front-ends, and converting the second into the first later means rewriting the reader.

On a home server the implementation is deliberately boring: extract pages on first read into data/pages/<uuid>/<n>.<rendition>.jpg, serve them with the same FileResponse machinery covers already use, and let the browser cache them. No CDN, no object storage, no extra moving parts. The interface is what matters, not the backend.

At scale the same interface points at a bucket and a CDN, and the extraction moves into the existing background job runner. The reader does not change.

Cost now: a page cache and a URL helper. Cost later, if skipped: rewriting the reader.

B. Media URL indirection — cheap, do it with A

One function — media_url(kind, comic, **params) — that every template and route goes through for covers and pages. Local deployments get /covers/12_t.jpg. Hosted deployments get a signed CDN URL. Retrofitting this means touching every template that renders an image, which is most of them.

C. Atomic job claiming — cheap, do it when a second worker exists

UPDATE import_job SET status='running', claimed_by=?
 WHERE id=? AND status='pending'

…and check the row count. That single change makes N workers safe. It is a twenty-line change and it is not needed until there is a second worker, so there is no rush — but it is worth knowing it is the only thing standing between the current job runner and a multi-worker one, because the queue is already a database table rather than something in memory.

D. Shared throttle state — cheap, do it when there are two instances

Move failed-login counts into the database. Not urgent with one process; it becomes a real security hole with two, because the limit is per-process.

E. Storage abstraction — define the seam, implement when needed

A storage.py with open(key), put(key, data), url(key), and a storage_backend / storage_key pair on comic alongside path. The local backend is a thin wrapper over the path that already exists.

Do not write the S3 backend speculatively. Define where it would go, keep archives.open_comic() taking a file-like object rather than only a path, and the port stays a bounded job.

F. Postgres — not now, and possibly not ever

Keep the SQL boring and portable, and keep the genuinely SQLite-specific pieces behind functions that already exist:

  • FTS5 (db.reindex_comic, library.fts_query)
  • VACUUM INTO (backup._snapshot, already has a fallback)
  • PRAGMA user_version migrations (db.init_db)

Everything else is ordinary SQL. Adopting an ORM now would tax every feature for a migration that may never happen; the schema is portable and the SQL-specific surface is three modules.


4. The catalogue fork, resolved

An earlier version of the roadmap left a question open: should the catalogue be shared with per-user overlay tables, or should each account own its own comic rows (which is what is built)?

Framed as a household efficiency question, the shared catalogue wins — two people who own the same issue would not store its metadata twice.

Framed commercially, it inverts, and decisively:

Deduplicating identical files across accounts means storing one copy and serving it to several people who each claim to have supplied it. That is much closer to distributing the content than to storing it.

For a service holding user-supplied copyrighted material, that is the wrong posture. Per-user rows keep each account's library isolated, which is what you want when responding to a takedown, and it means one person's metadata edits can never appear in another person's library.

Decision: keep per-user rows. Sharing, when it is wanted, should be an explicit grant — an access table naming who may see which comic — rather than a shared catalogue that everyone implicitly draws from. The current design is already the right one; it just needed the commercial lens to see why.


5. The constraint that is not technical

Hosting other people's comics is a copyright question before it is an architecture question, and it shapes the architecture more than any of the above.

The defensible shape is bring-your-own-content: people upload files they already own, the service never seeds a catalogue, there is a working takedown process, and content is never deduplicated across accounts. Everything in this document is compatible with that shape — and section 4 is a direct consequence of it.

A service that supplies comics is an entirely different business with licensing at its centre, and none of this analysis applies to it.


6. What it would cost

Storage is cheap and egress is not, and Longbox's egress scales with reading, not with library size.

A rough shape: someone reading 100 issues a month at ~40 MB each moves about 4 GB. Ten thousand such readers is ~40 TB a month leaving a CDN. That is the dominant line item by a wide margin — the database, the application instances and the object storage together are noise next to it.

Two practical consequences:

  • Renditions pay for themselves immediately. Serving a phone a 900 px-wide WebP instead of a 2400 px JPEG is a large multiple off the biggest cost.
  • Cache hit ratio is the business metric. Pre-extracting pages and letting the CDN hold them matters more than any application optimisation.

7. Sequence

  1. Now — build the reader against a page-URL interface, with a local page cache behind it (A and B). Nothing else changes.
  2. When a second machine is wanted — atomic job claiming and shared throttle state (C and D); run the web process and the worker separately.
  3. When readers are not local — implement the S3 storage backend and put a CDN in front, with the page cache as its origin (E). The reader is untouched.
  4. When one database is not enough — port to Postgres (F). This is a real project, but a bounded one, and it is the last thing to break rather than the first.

The only step that is expensive to get wrong is the first, and it is expensive precisely because it is about the reader's interface rather than its implementation.