visar.log
Technical notes from building things
← all posts

Why Opening the Reader Froze: HTTP/1.1 Connection Starvation from Sprite Polls

The Symptom

Search for manga. See the gallery list with thumbnail strips loading. Tap a gallery to open the reader. Nothing happens. The reader page is blank — no images load. Wait a few minutes for the strips to finish generating, and suddenly the reader images appear all at once.

Strips and reader images were supposed to be independent. They weren’t.

The Architecture

The Hitomi reader serves everything through a single Express server on localhost:29749. Thumbnail strips are sprite sheets — up to 163 thumbnails composited into one wide WebP by sharp. The backend generates them in worker threads and caches to disk. If a strip isn’t cached yet, the endpoint returns 202 Accepted and the frontend polls with exponential backoff (1s, 1.5s, 2.25s, up to 5s).

A page of 25 galleries means ~25 concurrent sprite poll loops, each making HTTP requests every 1-5 seconds.

Reader images are full-resolution pages fetched from the same origin.

The Bottleneck

HTTP/1.1 allows a maximum of 6 concurrent connections per origin. With 25 galleries polling for sprites, those 6 slots are permanently saturated. Each poll completes quickly (the 202 response is tiny), but by the time one finishes, another gallery’s poll fires immediately to take the slot.

When the reader opens and tries to fetch page images, those requests queue behind the sprite polls. The browser won’t send them until a connection frees up, but connections never free up because the polling loops keep refilling them.

The First Fix That Didn’t Work

Added a Svelte 5 $effect in each GalleryRow that watches viewMode:

$effect(() => {
    if (appState.ui.viewMode === 'reader') {
        abortController?.abort();
    } else if (stripContainer) {
        fetchSprites();
    }
});

This should abort all sprite fetches when the reader opens. It didn’t work. The reader images still loaded slowly.

Why: Svelte 5 Effects Are Asynchronous

In Svelte 5, $effect doesn’t run synchronously when its dependencies change. It’s scheduled as a microtask — it runs after the current execution context completes.

The sequence when you tap a gallery:

  1. openReader() sets viewMode = 'reader'
  2. Reader component starts mounting, queues image fetches
  3. Reader image fetches queue behind active sprite connections
  4. …microtask boundary…
  5. $effect in each GalleryRow fires, aborts sprite fetches
  6. Connections finally free up, reader images start loading

Steps 2-3 happen before step 5. The reader’s fetch calls are already queued behind occupied connections by the time the abort fires.

The Fix: Synchronous Centralized Abort

Added a Set<AbortController> to UIState. Each GalleryRow registers its controller on creation and unregisters on destroy:

class UIState {
    private _spriteControllers = new Set<AbortController>();

    registerSpriteController(c: AbortController) {
        this._spriteControllers.add(c);
    }

    abortAllSprites() {
        for (const c of this._spriteControllers) c.abort();
        this._spriteControllers.clear();
    }
}

openReader() calls abortAllSprites() as its first action, before setting any state:

openReader(gallery: Gallery, startPage: number, uiState: UIState) {
    uiState.abortAllSprites(); // synchronous — frees connections NOW
    this.activeGallery = gallery;
    this.currentPageIndex = startPage;
    this.saveProgress(gallery.gallery_id, startPage);
    uiState.setView('reader');
}

AbortController.abort() is synchronous. It immediately cancels all in-flight fetch() calls sharing that signal. The browser drops those connections. By the time the reader component mounts and makes its first image request, all 6 connections are available.

The Broader Lesson

HTTP/1.1’s 6-connection limit turns any polling pattern into a potential denial-of-service against your own app. The 202-poll pattern for sprite generation was correct for the backend (non-blocking, no held connections server-side), but created client-side connection starvation.

Reactive frameworks make this worse because state changes and their side effects aren’t synchronous. If you need something to happen before a view transition — like freeing network resources — you can’t rely on reactive effects. You need imperative, synchronous cleanup in the transition function itself.