DEV Community

FlipRead
FlipRead

Posted on

5 Performance Bugs That Nearly Broke My Flipbook Engine (Plus What Happens When Your AI API Fails Mid-Generation)

Part 4 in an ongoing series about building FlipFlow, a Free for a limited time online flipbook maker that converts PDF, PPT, Word, and images into interactive HTML5 flipbooks. If you're new here: Part 1 covers the page-flip physics and AI pipeline, Part 2 covers PDF parsing, and Part 3 untangles flipbook maker vs. interactive PDF vs. digital catalog terminology.

Building the demo version of a page-flip engine is the fun part. Keeping it from crashing on a five-year-old Android phone with 20 open tabs is where you actually learn something.

This post is the "things broke and here's why" post. Five real bugs, what caused them, and the fix — plus what happens when the AI generation layer times out mid-storybook (spoiler: it's not graceful by default).

TL;DR — the five bugs

  1. Canvas memory leak that crashed mobile Safari after ~15 page flips
  2. Layout thrashing from reading DOM measurements during animation
  3. Full-resolution images loading eagerly, causing multi-second jank on large PDFs
  4. AI generation requests failing silently on rate limits, leaving users staring at a spinner
  5. iOS Safari swallowing touch events because of a missing passive: false

Bug 1: The canvas memory leak nobody warns you about

A page-flip engine constantly creates new ImageBitmap or canvas contexts as pages render. Early on, FlipFlow would slow to a crawl and eventually crash the tab on mobile Safari after flipping through roughly 15–20 pages of a document.

The cause: I was creating a new offscreen canvas per page render and never explicitly releasing it. V8 and JavaScriptCore are supposed to garbage-collect unused canvases, but large ImageBitmap objects sitting in a closure (my animation loop kept references "just in case") don't get collected until memory pressure forces it — which on mobile Safari means a hard crash instead of a graceful GC pass.

// Before: leaking a new bitmap reference every page turn
let pageCache = {}; // never evicted — grows unbounded

async function loadPage(index, imageUrl) {
  const bitmap = await createImageBitmap(await fetch(imageUrl).then(r => r.blob()));
  pageCache[index] = bitmap; // stays in memory forever
  return bitmap;
}
Enter fullscreen mode Exit fullscreen mode
// After: LRU-style cache with explicit close()
const MAX_CACHED_PAGES = 6;
const pageCache = new Map();

async function loadPage(index, imageUrl) {
  if (pageCache.has(index)) return pageCache.get(index);

  const bitmap = await createImageBitmap(await fetch(imageUrl).then(r => r.blob()));
  pageCache.set(index, bitmap);

  if (pageCache.size > MAX_CACHED_PAGES) {
    const oldestKey = pageCache.keys().next().value;
    pageCache.get(oldestKey).close(); // explicitly release the bitmap
    pageCache.delete(oldestKey);
  }
  return bitmap;
}
Enter fullscreen mode Exit fullscreen mode

ImageBitmap.close() is easy to forget because it's not required in most tutorials — it only bites you once you're rendering dozens of pages in a session.

Bug 2: Layout thrashing during the flip animation

I was calling inside the same animation frame where I was also setting styles, to recalculate the fold position relative to the container on every frame. That forces the browser to synchronously recalculate layout — a classic "layout thrashing" pattern — and it was quietly costing 4-6ms per frame, enough to drop below 60fps on mid-range devices.

The fix was to measure once, outside the animation loop, and cache it:

// Before: measuring every frame inside the loop
function animationLoop() {
  const rect = bookContainer.getBoundingClientRect(); // forces layout recalculation
  drawFlippingPage(ctx, currentProgress, rect.width);
  requestAnimationFrame(animationLoop);
}
Enter fullscreen mode Exit fullscreen mode
// After: measure once, invalidate only on resize
let cachedRect = bookContainer.getBoundingClientRect();
window.addEventListener('resize', () => {
  cachedRect = bookContainer.getBoundingClientRect();
}, { passive: true });

function animationLoop() {
  drawFlippingPage(ctx, currentProgress, cachedRect.width); // no layout read here
  requestAnimationFrame(animationLoop);
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb I now follow everywhere in the rendering layer: never read layout properties inside ai requestAnimationFrame callback. Read once, write many.

Bug 3: Eager image loading on large documents

For a 60-page catalog PDF, loading every page's full-resolution image up front meant users waited several seconds staring at a blank flipbook before the first page even appeared.

The fix was straightforward but easy to skip under deadline pressure: load the current page and the next two pages eagerly, and lazy-load everything else on approach.

function preloadAroundCurrentPage(currentIndex, totalPages, loadPage) {
  const preloadRange = [currentIndex - 1, currentIndex, currentIndex + 1, currentIndex + 2];
  preloadRange
    .filter(i => i >= 0 && i < totalPages)
    .forEach(i => loadPage(i)); // loadPage() is a no-op if already cached
}
Enter fullscreen mode Exit fullscreen mode

Combined with the LRU cache from Bug 1, this dropped initial load time on large documents by more than half, without changing the perceived flip performance at all.

Bug 4: What actually happens when your AI API call fails mid-generation

This one isn't a rendering bug — it's a UX and reliability bug in the AI storybook feature. Image generation APIs have rate limits, and my original concurrency-3 batching approach (from Part 1) had no retry logic. If request #5 out of 8 got rate-limited, the entire storybook generation would silently fail, and the user would just... watch a progress bar stall.

The fix: exponential backoff with jitter, scoped per-request so one failure doesn't kill the whole batch.

async function generateImageWithRetry(scenePrompt, mood, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await generateImage(scenePrompt, mood);
    } catch (err) {
      const isRateLimit = err.status === 429;
      const isLastAttempt = attempt === maxRetries;

      if (!isRateLimit || isLastAttempt) {
        // Not a retryable error, or we're out of attempts — fall back to a placeholder
        return getPlaceholderIllustration(mood);
      }

      const baseDelay = 500 * Math.pow(2, attempt); // exponential backoff
      const jitter = Math.random() * 200;
      await new Promise(resolve => setTimeout(resolve, baseDelay + jitter));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The important design decision here isn't the retry logic itself — it's the fallback. A failed page shouldn't fail the whole book. FlipFlow now falls back to a clean placeholder illustration for that one page rather than blocking the entire storybook, and the user still gets a complete flipbook instead of a spinner that never resolves.

Bug 5: iOS Safari silently ignoring preventDefault()

Touch dragging worked perfectly on Android and desktop but felt "sticky" and unresponsive specifically on iOS Safari. The cause: I'd registered the touchmove listener without { passive: false }, so iOS Safari was treating it as a passive listener by default and ignoring my preventDefault() call — meaning the page would try to scroll and flip at the same time.

// This silently fails to prevent scroll on iOS Safari
canvas.addEventListener('touchmove', handleTouchMove);

// This works correctly
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
Enter fullscreen mode Exit fullscreen mode

One line. Hours of debugging. Classic.

What this means if you're building a flipbook maker (or any canvas-heavy tool)

  • Explicitly release ImageBitmap/canvas resources — don't trust GC timing on mobile
  • Never read layout properties inside your animation loop
  • Preload a window around the current page, not the whole document
  • Any AI-generation feature needs per-request retry + a fallback path, not just a top-level try/catch
  • Always pass { passive: false } when you need preventDefault() on touch events

None of these are exotic problems — they're the boring, unglamorous 20% of building a real-time interactive tool that never makes it into the demo GIF.

What FlipFlow actually looks like after all this

What is FlipFlow.jpg

Try a live example — this one was generated directly in FlipFlow:

→ Open the live flipbook demo

If you want to convert a PDF, PPT, Word doc, or image into your own interactive flipbook: flippingbooks.org — free, no account required. Chinese-speaking readers can find the same tool at flippingbooks.org/zh.

Up next in this series

I haven't written about the part that's arguably done more for FlipFlow's growth than any of the engineering above: a programmatic SEO strategy where every publicly shared flipbook automatically becomes an indexable page, with metadata auto-populated from the source filename. It's a passive, long-tail acquisition mechanism that a few flipbook/catalog platforms already lean on heavily — and it's a very different kind of problem from anything in this post. That's Part 5. Follow if you want it in your feed.


Found a similar canvas/mobile bug in your own project, or have questions about the retry logic? Drop a comment below — I read and reply to all of them.

Top comments (0)