How partial refresh works
GUIDEAn e-paper panel does not have a frame rate. It has waveform modes, each a different trade between speed, grey depth and how much of the last image it leaves behind. Understanding that trade is most of what separates a UI that feels instant on e-ink from one that flashes like a broken fluorescent tube.
On an LCD or OLED, pixels hold their state because the panel is continuously driven. Stop refreshing and the image is gone. An electrophoretic display works the other way round: the image is bistable, it persists with no power at all, and changing it costs energy and time. Every design consequence in this guide follows from that inversion.
What is physically happening
The imaging layer is a sheet of microcapsules, each holding charged pigment particles suspended in a clear fluid — classically white titanium-dioxide particles with a negative charge and black particles with a positive one. A voltage applied across the capsule pulls one species to the surface and the other out of sight. Remove the voltage and the particles stay where the viscosity left them. That is the bistability.
Moving those particles is slow in a way that has nothing to do with electronics. The switching time is dominated by the physical migration of pigment through fluid, which is why a full e-paper update takes hundreds of milliseconds while the controller driving it is idling. You cannot optimise your way past it; you can only avoid asking for it.
Worse, a single voltage pulse does not reliably land a particle in a precise intermediate position. To render grey levels the controller applies a waveform: a timed sequence of positive, negative and zero voltage frames, calibrated per panel batch and per temperature, that walks the particles to a target optical state. The waveform table is why panels ship with a factory .wbf file and why the same code looks different at 5 °C than at 25 °C.
The waveform modes you actually choose between
A controller — the EPDC on an i.MX SoC, or the vendor firmware on a small SPI panel — exposes a handful of named modes. The names below are the E Ink conventions and are near-universal, though timings vary by panel generation, size and temperature. Treat these as orders of magnitude, not datasheet values.
| Mode | Greys | Typical time | Flash | Leaves ghosting |
|---|---|---|---|---|
INIT | — | ~2000 ms | Yes, repeatedly | No — this is the reset |
GC16 | 16 | ~450–750 ms | Yes, full inversion | No |
GL16 | 16 | ~450–750 ms | Minimal | Slight |
DU | 2 | ~250 ms | No | Yes |
A2 | 2 | ~120 ms | No | Yes, accumulating |
INITclears the panel to a known state. It is the flash you see when an e-reader boots. Nothing in an application should trigger it.GC16— grayscale clearing, 16 levels — is the quality mode. It drives every pixel in the update region to black and to white before settling on the target, which is why it visibly inverts. That round trip is also what erases residual charge, soGC16is the mode that clears ghosting.GL16reaches the same 16 levels without the full clearing cycle. It is the mode for a page of text on a white background: no flash, nearly the same quality, at the cost of letting a little residue accumulate.DU— direct update — is 1-bit. Pixels move to black or white and grey pixels are left alone. It is fast and completely flash-free, which makes it the right mode for a changing number, a checkbox, a cursor.A2isDUpushed further, fast enough for crude animation and menu scrolling. EveryA2update leaves visible residue, so implementations track a counter and force aGC16after some number of them.
The damaged rectangle
A partial update is not free-form. The controller updates a rectangle, and everything inside that rectangle is driven with the chosen waveform whether or not it changed. The update pipeline is roughly: something marks a region dirty, the region is expanded to the controller's alignment constraints, a waveform mode is selected for it, and the panel is driven.
The consequence is the single most important fact for anyone writing UI on e-paper: the cost of an update is the bounding box of what changed, not the amount that changed. Updating one character in a header and one digit in a footer produces a rectangle covering the entire page. Two separate small updates would have been dramatically cheaper than one union.
It also explains why grey content is expensive to touch. If any pixel in the dirty region needs a grey level, the whole rectangle needs a 16-level waveform — the fast 1-bit mode is no longer available for it. A UI built out of pure black and white can stay in DU almost permanently; the same UI with a subtle grey background cannot.
What this means for the DOM
The browser has no e-paper API. It paints, the framebuffer changes, and the controller — or a compositor shim above it — infers the damaged region from what actually differs. Every layer between your code and the panel is doing damage tracking, so the DOM operations you choose decide the rectangle you get.
Reassigning innerHTML is the pathological case. Even when the resulting markup is byte-identical to what was there, the old subtree is destroyed and a new one is built, laid out and painted. Damage tracking sees the whole container as changed and the panel does a full-region refresh, in the slow grey-capable mode, for an update that changed one number.
// Full container repaint. On a panel: one large rectangle, GC16-class cost.
el.innerHTML = `
<span class="label">Queue</span>
<span class="value">${count}</span>`;
// Same visible result, one text node touched. On a panel: a small
// rectangle around the digits, eligible for a fast 1-bit update.
valueNode.textContent = String(count);
This is why EPaper components patch rather than re-render. Every component keeps references to the nodes it owns and updates them through helpers — patchText, patchAttr, patchBoolAttr — that compare before they write. A patch that finds no difference performs no DOM write, produces no damage, and costs the panel nothing.
// From core/dom.ts — the write only happens when the value really moved.
export const patchText = (node: Node, next: string): void => {
if (node.textContent !== next) node.textContent = next;
};
The same reasoning rules out CSS transitions and animations outright. A 300 ms fade is not a smooth gradient on e-ink — it is a request for a few dozen full-region waveform cycles, each one a visible flash, queued faster than the panel can drain them. EPaper resets transition and animation globally inside .ink-page rather than trusting authors to remember.
Designing an update rhythm
Once you accept the rectangle model, the design work becomes deciding when things change, not just how.
- Batch on a clock, not on an event. A dashboard polling four endpoints should paint once when all four have answered, not once per response. Four small rectangles arriving together are cheaper than four unions arriving apart.
- Keep volatile content spatially together. Two numbers that change every minute belong in the same corner. Put them at opposite ends of the page and every update damages everything between them.
- Keep volatile content pure black and white. Grey in the dirty region forces the slow waveform. Grey outside it costs nothing.
- Budget a clearing refresh. Count your fast updates and spend a
GC16when the residue becomes visible — commonly every 10 to 50 partial updates, tuned by eye on the actual panel. - Never update faster than the panel settles. Queueing updates faster than roughly 4 Hz on a
DU-class mode does not make the UI more responsive; it makes it permanently mid-transition.
Measuring it
None of this is guesswork you have to do by eye. Damage is observable from JavaScript: a MutationObserver tells you exactly which nodes an interaction touched, and the union of their bounding boxes is a good proxy for the rectangle the panel will be asked to drive.
// Rough dirty-rectangle probe. Union of every touched node's box,
// as a share of the viewport.
const seen = new Set();
new MutationObserver((records) => {
for (const r of records) {
seen.add(r.target.nodeType === 1 ? r.target : r.target.parentElement);
}
}).observe(document.body, {
subtree: true, childList: true, characterData: true, attributes: true,
});
function dirtyArea() {
let box = null;
for (const el of seen) {
if (!el) continue;
const r = el.getBoundingClientRect();
box = box ? {
top: Math.min(box.top, r.top), left: Math.min(box.left, r.left),
right: Math.max(box.right, r.right), bottom: Math.max(box.bottom, r.bottom),
} : { top: r.top, left: r.left, right: r.right, bottom: r.bottom };
}
if (!box) return 0;
return ((box.right - box.left) * (box.bottom - box.top)) /
(innerWidth * innerHeight);
}
EPaper runs exactly this idea as a test gate. npm run test:refresh drives each component through a representative interaction and asserts a budget on mutation count, node churn and dirty area — so a change that quietly replaces a subtree fails CI instead of being discovered on a panel months later.
Summary
- E-paper is bistable: holding an image is free, changing it is expensive.
- Grey levels require a multi-frame waveform; 1-bit updates can skip it and are 3–5× faster.
- Fast modes leave ghosting that only a clearing waveform removes.
- Update cost is the bounding box of the change, not its area — so scatter is expensive and grouping is cheap.
- In the browser, that makes surgical DOM patches a hardware concern rather than a micro-optimisation.
EPaper encodes these rules so you do not have to re-derive them per component — see the feature overview for how they show up in the API, or the component list for what ships.