← Field Notes
Field Note · the wrong platform model

The canvas rendered once. The phone threw it away.

On mobile, a page you had already viewed went blank when you scrolled back to it. The canvas was fine. The phone had quietly reclaimed its memory. Here is the commit.

Builds production software, and cleans up the AI-generated code that breaks it. pdflokal, his open-source PDF toolkit, is one of the repos these notes come from.

pdflokal, our own PDF editor, draws each page onto a <canvas>. Draw a page once and it stays on screen while you scroll. That is how canvas behaves on a desktop, and it is how we built it: a page marked rendered was left alone and never redrawn. Render once, never re-render.

Then the reports came in from phones. A page you had already looked at would go blank when you scrolled back to it. White. The canvas element was still there, the right size, in the right place. It just had nothing on it.

The easy read is that our render broke. It did not. The pixels had been drawn correctly the first time. Something else came along afterward and threw them away.

The phone reclaims the memory

A canvas keeps its bitmap in GPU memory. On a memory-constrained device, a phone mid-scroll with several heavy PDF pages in play, the browser is allowed to reclaim that memory whenever it needs it. Android Chrome does it; iOS Safari does it under pressure. It purges the canvas backing store to stay alive. The DOM element survives the purge; the pixels do not. The canvas paints blank, and nothing errors, because from the browser’s point of view nothing went wrong. It made room, which is its job.

“Rendered” was a claim about the past

Our code kept a flag on each page, pc.rendered. Once a page rendered we set it true and moved on. An IntersectionObserver watched pages scroll in and out of view, but when a rendered page scrolled back the observer saw rendered === true and did nothing, because the whole policy was render once, never re-render. So the one path that could have repainted the blank canvas was the exact path we had told never to fire. Blank stayed blank, until a zoom or a resize forced everything to redraw.

Render once, never re-render.The policy. It was true on the platform we designed for and false on the one people used. The flag said the page was rendered. All it actually knew was that the page had been rendered.

The fix was not to render again

The obvious repair is to re-render the blank page. We had tried that months earlier, on an earlier version of this bug, and abandoned it: re-running PDF.js on the scroll path is async, and it flashed white while it worked. The thing we had missed is that we were already saving each rendered page as an ImageData bitmap in a plain JavaScript object (ueState.pageCaches). That cache lives in CPU memory, not GPU memory, so the purge never touched it.

The fix (843d6b8) was a small method, restoreCanvasFromCache: when a rendered page comes back into view, putImageData the cached bitmap straight back onto the canvas. Synchronous, about a millisecond, no flash. On a healthy canvas it overwrites identical pixels and costs nothing; on a purged one it is an instant repaint. rendered stopped meaning “the GPU still holds this” and started meaning, honestly, “we have a copy of this.”

What this actually teaches

The bug was not in the drawing code. It was in a belief about the platform: that once you render something, it stays rendered. On a desktop that belief is close enough to true that you can ship on it for years. On a phone it is simply false, and the falseness only shows up under conditions a demo never creates: fast scroll, low memory, many heavy pages. The code ran correctly. The mental model underneath it did not match the machine.

This is a shape AI-built code takes easily, because a model writes to the common case. Ask it to render pages to canvas and it will, correctly, for the desktop path it has seen ten thousand times. It will not warn you that a mobile browser can revoke the result, because that is not a fact about your code, it is a fact about the runtime, and the runtime is exactly what a language model is not looking at. Fluent rendering code and a wrong belief about the platform look identical on screen, right up until the platform does the thing the belief said it would not.

So when something works everywhere except on a phone, and works there too until you scroll fast, stop staring at the render. Ask what the platform is allowed to do to your work after you are done. “It rendered” is a fact about a moment. “It is still rendered” is a fact about now, and on mobile the two come apart.

The receipt, public
  • The fix: restore the purged canvas from the CPU-side ImageData cache instead of re-rendering, in a commit titled “mobile canvas blank-after-purge, restore from ImageData cache” (2026-06-04)843d6b8

Why does an HTML canvas go blank when I scroll on mobile?

Because the browser reclaimed the memory. A canvas keeps its bitmap in GPU memory, and on a memory-constrained device the browser is allowed to purge that backing store under pressure, which happens during fast scroll with several heavy pages in play. The DOM element survives but paints blank, and nothing errors, because from the browser's point of view nothing went wrong. It is not your code drawing wrong; it is the platform discarding pixels you already drew.

How do you restore a blanked canvas without re-rendering?

Save the pixels as ImageData when you first draw, and repaint them with putImageData when the canvas comes back into view. It is synchronous and fast (about a millisecond for an A4 page), so it avoids the white flash you get from re-running an async render on the scroll path. In pdflokal the bitmap was already cached in ordinary CPU memory, which the GPU purge never touches, so restoring was a one-line repaint rather than a re-render.

Why is 'it rendered once' not enough on mobile?

Because rendering is not permanent there. On a memory-constrained device the browser can throw your GPU-backed pixels away at any time, so a flag that says a page is rendered describes the past, not the present. Code that trusts the flag never repaints, and blank stays blank. Treat 'rendered' as 'I have a cache of this', not 'the screen still shows it'.