Frame the picture
One still image the display shows. A 60 Hz iPad shows 60 per second, one every 16.7 ms. A 120 Hz Android phone shows one every 8.3 ms. That gap is the beat.
Performance explainer · verified against main on September 5, 2026
A drawing app for two-year-olds has one job: the ink must appear where the finger is, on the very next picture the screen shows. This page builds up, one brick at a time, how the app spends its 16.7 milliseconds per frame, and catalogs every mechanism in the code that protects that budget.
Start here
A screen never shows motion. It shows a stack of still pictures, one after another, fast enough that the eye fills in the movement. Each picture has a deadline. Every mechanism on this page exists to hit that deadline while a child scribbles as fast as they can.
One still image the display shows. A 60 Hz iPad shows 60 per second, one every 16.7 ms. A 120 Hz Android phone shows one every 8.3 ms. That gap is the beat.
The single lane in which the browser runs the app's JavaScript, computes styles, lays out the page, and paints. If the lane is busy when the beat arrives, the frame is late.
The browser's "call me right before the next frame" hook. Splotch does its per-frame drawing work inside it, so the work lands exactly once per picture.
A frame whose work ran past the beat. The display repeats the previous picture, so the ink lags the finger. Too many in a row reads as stutter.
Of all the time a finger is touching the glass, the share spent inside late frames. Splotch ships a drawing only when a real device holds this at or below 1%.
Time from a finger movement to the frame that showed its ink. Gated at P95 ≤ 20 ms, P99 ≤ 33 ms, and never above 50 ms, in absolute milliseconds because a 50 ms hitch is visible at any refresh rate.
Drag the slider to change how much work runs per frame. Watch when frames start to miss.
The same 9 ms of work fits comfortably at 60 Hz and misses every frame at 120 Hz. That is why the phone rows of the performance matrix are scored at their own beat, and why so much of this page is about making the per-frame work small rather than merely "fast enough on my laptop".
The map
A hundred and one optimizations sound like a pile. They are not. Read the code and the same six habits show up again and again. The first four do most of the work; the last two cover the edges. Each card is a section below.
A finger produces more samples than the display can show. Collect them and paint once per picture.
Split the paper into tiles, track the dirty rectangle, and never repaint or copy a pixel the stroke did not reach.
Anything that can wait, waits: until no pointer is down, input has been quiet, and two frames in a row ran on time.
The first stroke should work before the app has finished loading. Everything not needed for it loads later, and tests pin the boundary.
Let the compositor do transforms and blends, and let workers do encoding and rasterization, so the drawing lane stays clear.
Cache aggressively, download in the background, decode before showing, and never start a big fetch while a child is drawing.
Habit 1
A touch digitizer reports finger positions faster than the screen can show them. On the iPad the engine measured 1.9 to 4.2 movement samples per painted frame. If every sample triggered painting, the same frame would be drawn two to four times over, and the extra passes would be invisible.
Two terms make this concrete. A pointer event is the browser telling the page "the finger moved to x, y". Coalesced events are the in-between samples the digitizer recorded since the last event; the browser batches them and exposes them through getCoalescedEvents(). Splotch reads every one of those samples so a fast scribble curves instead of turning into long straight chords. It just does not paint on each of them.
Instead, each pointer has a queue. A movement pushes its samples onto the queue and asks for one animation frame. When that frame arrives, the queue drains into a single render operation per pointer, carrying every sample as its own curve segment. The ink is identical to painting per event, and the per-operation cost (finding which tiles the stroke touches, capturing undo pixels, running the brush) is paid once. Lifting the finger drains the queue synchronously so nothing is lost.
A 100 ms window of drawing on a 60 Hz screen. Change the digitizer rate and the painting strategy.
Per frame, the operation count is pinned to the display rate no matter how fast the digitizer runs. Per event, it scales with the hardware, and a 240 Hz stylus digitizer would quadruple the painting work for zero visible gain.
function flushAll() {
if (rasterFrame !== 0) {
cancelAnimationFrame(rasterFrame);
rasterFrame = 0;
}
drainQueues(); // finger lifted: paint what is left right now
}
function schedule() {
if (rasterFrame !== 0) return; // a frame is already booked
rasterFrame = requestAnimationFrame(() => {
rasterFrame = 0;
drainQueues(); // every pointer's queued samples become one op each
});
}
Mapping a screen point to paper needs the canvas rectangle. Asking the browser for it forces a layout (recomputing every element's position) on every move. The engine caches the rectangle and refreshes it only on resize, scroll, or rotation.
canvasMeasure.tsThe crayon brush reads its parameters three times per operation. Those reads go through non-cloning accessors so the per-move code creates no throwaway arrays, a rule written into the repo's Svelte guidance.
crayonBrush.tsThe ring that follows each finger stores its pending position in a plain record and moves once per animation frame, latest position wins, with a GPU-composited transform.
PointerHalos.svelteIf a frame stalls, the queue could merge dozens of samples into one operation whose bounding box spans tiles the ink never touched. Crayon operations split after 8 merged moves so that cannot happen.
strokeRasterQueue.tsA resize refreshes the cached rectangle immediately but rebuilds the tile backings only after 150 ms without another resize, so a rotation's burst of events pays for one wipe and repaint, not five.
engine.tsThe Settings pane mounts its below-the-fold sections one per frame after its fly-in, and the What's New list reveals one release per frame; mounting them together measured 43 to 47 ms on desktop WebKit.
WideShell.svelte · WhatsNewSection.svelteCAPACITOR=true signal (ADR-0146).Habit 2
A stroke changes a sliver of the paper. If the app treated the paper as one big bitmap, every stroke, every undo, and every theme switch would pay for the whole surface. So the paper is not one bitmap. It is twenty.
The visible drawing surface is a grid of 4 columns by 5 rows of small canvases called tiles. Every render operation carries a conservative bounding box: the smallest rectangle that could contain its ink, padded by half the stroke width plus two pixels for anti-aliasing. Before painting, the renderer checks that box against each tile and skips the ones it cannot touch. That check is called culling, and it is the single decision the rest of this section builds on.
A tile that has never been inked stays hidden. A hidden canvas costs the compositor (the part of the browser that stacks layers into the final picture) nothing, and it has no backing memory until the first stroke reaches it. On the target iPad, mutating one large canvas hit a cliff somewhere between 1.6 and 2.3 megapixels per surface; twenty tiles sit well under it (ADR-0085).
Drag on the paper. Only the tiles your stroke's padded box reaches light up.
The undo figure is the dirty rectangle's area. Undo does not snapshot the paper; it snapshots each touched tile once, before the first change, then crops that copy to the rectangle the command actually dirtied. A short scribble in a corner keeps a few kilobytes, not a screen's worth of pixels. Draw a long diagonal to see the price of a conservative box: tiles inside the rectangle but away from the ink still pay.
for (const [index, tile] of liveTiles.entries()) {
if (geometryIntersectsTile(op, tile)) {
ensureNormalTileBacking(tile); // first touch allocates the bitmap
prepareTileForMutation(tile, index);
if (command && !command.wasEmpty) {
undoPatches.capture(command, tile, index, opDeviceBounds(tile, op));
}
// ...render the op into this one tile
}
}
Undo keeps a patch per touched tile: the pre-change pixels, cropped to the dirty rectangle at commit. Undoing is a clear plus a copy back, which is why it stays under a frame even on a full paper. Patches live under a byte budget of six papers' worth of pixels with a minimum of two commands kept, and history is capped at 20 steps. Commands older than the undo window are folded: rasterized into an offscreen base layer, one every 1.5 seconds of idle, never while a finger is down.
const budget =
liveTiles.reduce((total, tile) => total + tile.width * tile.height * 4, 0) *
TILED_UNDO_PATCH_BUDGET_PAPER_MULTIPLE; // 6 papers of RGBA
let bytes = history
.slice(-undoableCommands)
.reduce((total, command) => total + undoPatches.bytes(command), 0);
while (undoableCommands > MIN_TILED_UNDO_COMMANDS && bytes > budget) {
// drop the oldest patch; the command stays replayable from its vectors
}
The device pixel ratio is how many physical pixels one CSS pixel covers. Tiles rasterize at the device ratio capped at 2. A 3× panel would cost 9× the pixels for detail a finger cannot draw, and the value is fixed for the session so a mid-session change never rescales every tile.
engine.ts"Is the paper blank?" is answered by copying each tile at a quarter scale into one persistent scratch canvas flagged for reads, about 16× fewer pixels, and stopping at the first tile with ink. The main canvases never get a read flag that would push them off the GPU.
emptyScan.ts · tiledRenderer.tsAfter a clear, wiping a hidden tile's backing is postponed by two presented frames, so the wipe never lands inside the gesture's own frames.
tiledSurfaces.tsClearing hides all tiles instantly but captures their undo snapshots one per frame; if a new stroke reaches a tile first, that tile's snapshot is taken on the spot.
progressiveClearCapture.tsOn the web the crayon redraws its glaze inside each operation's padded rectangle. Trying the frame's union of rectangles measured 2.62% lost frame time and the whole pass's bounds 2.18%; cost tracks area, not the number of copies.
crayonPassBuffer.tsThe drag-to-clear coachmark's two looping animations once ran forever on their hidden state, 72% of all animation style invalidations in a trace. They now apply only under .visible: 5,847 invalidations became 0.
Habit 3
Plenty of work is worth doing but not worth doing now: warming a font, mounting a dialog nobody has opened, folding old undo history, checking whether the eraser emptied the page. All of it goes through one scheduler that refuses to run while a child is drawing.
Idle time is a stretch where the main thread has nothing urgent to do. Chrome exposes it through requestIdleCallback. Safari does not, so the scheduler approximates idleness itself: no pointer is down, no input has arrived for 300 ms, and two consecutive animation frames arrived within 25 ms of each other, which proves the lane is clear. If any check fails it tries again 250 ms later. A second, stricter entry point used for background mounting waits 750 ms of quiet and spaces its slices 750 ms apart, and it uses the cooperative check even where the native callback exists.
Press and hold the button to simulate a stroke. Deferred jobs wait; release and watch them drain.
Nothing is scheduled yet. The queue fills as soon as you touch the paper.
frame = requestAnimationFrame((first) => {
frame = requestAnimationFrame((second) => {
if (cancelled) return;
if (second - first > FRAME_BUSY_GAP_MS || !signalsAreQuiet()) {
requeue(); // a frame ran long, or input arrived: try again later
return;
}
fn(); // two clean frames in a row: the lane is free
});
});
Seven dialogs and the install banner ship in one lazy chunk imported at idle, then mount one per background slice. A tap on any of them jumps the queue and mounts immediately.
bootHiddenOverlays.tsAfter an eraser lift, checking whether the page went blank costs 4.5 to 12.3 ms on Android against an 8.3 ms frame. It only enables a button, so it waits 400 ms of idle.
idleEmptyScan.tsThe crayon's paper-tooth fields are built from fixed seeds at idle after boot. Picking a color warms its wax tiles in 8-row chunks under a 2 ms per-frame deadline; a stroke that arrives first builds synchronously.
crayonBrush.tsThe offline cache is not installed at load. A first visit registers it only after three committed strokes, then at idle, and not at all under the browser's data-saver flag, so precaching never saturates a slow connection during the first strokes.
+page.svelteThe save compositor and its paper texture (a 226 ms fetch) warm at idle so the first tap on the camera does not stall.
engine.tsExtra coloring books download in the background, and every single file waits for idle before its fetch, so a book install never competes with a stroke.
webStore.tsHabit 4
A toddler does not wait for a spinner. The page arrives as finished HTML with its styles inlined, the drawing engine starts before the framework has even loaded, and everything that is not needed for the first stroke is loaded later, in a separate file, with a test that fails if it creeps back in.
Two words carry this section. Hydration is the moment the JavaScript framework (Svelte) takes over the static HTML and makes it interactive; on the iPad it is a ~375 ms block of main-thread work. A bundle is one JavaScript file the browser downloads and parses before it can run; the startup bundle is the set of files the page needs before the first frame. Every module kept out of it is bytes not downloaded, not parsed, and not executed before the child can draw.
Not to scale. The point is the ordering: the engine is live before hydration starts, and the idle work only begins after it ends.
export function adoptDrawingCanvas(canvasElement: HTMLCanvasElement, options: InitOptions = {}) {
if (!engineOwnsCanvas(canvasElement)) return initDrawingCanvas(canvasElement, options);
attachCallbacks(options); // earlyBoot already drew the first strokes
callbacks.onUndoStateChange?.(canUndo);
callbacks.onCanvasEmptyChange?.(canvasEmpty);
notifyViewChange();
return { teardown: teardownEngine };
}
web/tests/startup-bundle.spec.ts loads the built app, collects every module the page preloads, and fails if a save-pipeline or coloring-pack marker appears in any of them. It also checks that the markers still exist in the build, so the test cannot pass by accident. One consequence: a tiny helper needed on both sides of the boundary is duplicated rather than shared, because a single static import would let the bundler re-partition the chunks. A drift test keeps the two copies identical.function loadScreenshotModule() {
if (screenshotModulePromise) return screenshotModulePromise;
const loading = import('$lib/drawing/screenshot').catch((error) => {
if (screenshotModulePromise === loading) screenshotModulePromise = null; // retry next tap
throw error;
});
screenshotModulePromise = loading;
return loading;
}
An inline script reads saved settings (theme, brush, drawer state, button scale) from localStorage before the first paint and stamps them onto <html>, so a returning child sees the right theme with no flash. A drift test pins the script's keys to the app's.
inlineStyleThreshold: Infinity puts every stylesheet into the prerendered head, which ended a flash of unstyled content on iPadOS.
The bundler compiles for Chrome 111, Firefox 114, and Safari 16.4 rather than a lowest common denominator, so it emits smaller modern syntax. A test fails if the target list drifts from the documented floor.
browserTargets.tsProfiling marks and the dev harness are literal false in production, so the bundler removes their code, and a release scan rejects a build that kept any.
All setting reads are synchronous from localStorage so reactive state initializes with the right value on the first render. On native, writes mirror to the platform store without being awaited.
storage.tsOverlapping callers of a lazy import or a fetch share one in-flight promise; a promise that rejects resets itself so the next caller retries instead of inheriting the failure.
singleFlight.ts · idb.tsHabit 5
The main thread is where strokes are painted, so anything else that can run somewhere else, should. Two "somewhere elses" exist in a browser: the compositor, which applies transforms and blends on the GPU, and web workers, which run JavaScript on a separate thread.
will-change: transform). Rotating the device with a drawing on screen moves a layer; it does not touch a single tile pixel. A test asserts the promotion is present.hidden, so the compositor never stacks it. Un-hiding dozens of transparent canvases used to cost 40 to 50 ms on every theme switch.will-change or contain so their motion does not invalidate the page around them.translate3d, which the compositor animates without a layout or paint.ImageBitmap, hands them to a worker with an OffscreenCanvas, and composes, downscales the preview, and encodes there. The main thread never reads pixels back. WebKit was measured encoding 163 to 206 ms on the main thread when asked to do it "in parallel"..live-paper-view {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
/* Permanent promotion avoids the rotation undo spike; engine-rotation.spec.ts enforces it. */
will-change: transform;
}
The crayon is the most expensive brush because it is a texture, not a color: two passes of deterministic paper tooth, blended so that overlapping wax darkens. How that blend reaches the tile is the one place where the two shipping targets diverge, and the decision was remeasured three times this summer.
The original design (ADR-0068) kept two extra hidden canvases per tile, blended in CSS with mix-blend-mode: darken, so the live preview cost no pixel readback. On the native WKWebView those composited planes turned out to be the entire cost: with them present the crayon lost 1.21 to 1.87% of frame time, without them 0.02 to 0.46% (ADR-0148). The native build now applies the glaze arithmetic directly on the ink tile, per operation, with no buffer and no copy. The web build instead restores and re-glazes each operation's padded rectangle from an offscreen shadow (ADR-0147). The plane elements still exist in the DOM but are hidden all session and never allocated a backing.
configureCrayonDeposition(
__IS_CAPACITOR__ ? 'glaze-direct' : 'restamp',
() => activePointers.size > 0
);
Colorized crayon tiles are cached per color and pass, capped at 32 entries (palette size plus one custom color, times two passes). Eviction also resets the pattern cache so detached canvases cannot pile up.
crayonBrush.tsCanvas patterns for both the crayon and the Magic brush are cached per drawing context, and a one-entry memo skips re-hashing the six identical pattern lookups a crayon frame makes.
crayonBrush.ts · magicBrush.tsExtending the Magic sheet's edges by sampling the sheet itself triggered a full-surface flush in WebKit, about 100 ms of a 1.1 s theme freeze. Edge strips are drawn from the source image instead.
magicBrush.tsAfter a save, MobileSafari needs time to reclaim full-page surfaces, so a second tap within 4 s is coalesced into the first. The snapshot is taken at pointer-down, before the button's own animation.
screenshotTiming.tsThe PNG sent to the image API is re-encoded as WebP at quality 0.85 when the browser supports it and the result is smaller, a fraction of the bytes for flat-color art.
aiImage.tsThe drag-to-clear feedback is synthesized from oscillators driven by drag distance rather than loaded from an asset, so there is nothing to fetch and no length to match.
drawingSound.tsHabit 6
The network is the one resource the app cannot schedule. So the rules are about not needing it: cache what has already been seen, fetch in the background, and make sure nothing on screen ever waits on a byte that has not arrived.
Cache-Control lifetimes set by the Netlify configuration, in days.
Built JavaScript and CSS carry a content hash in their filename, so they can be cached for a year and marked immutable: a new build gets a new name. Sounds, icons, and coloring art are stable filenames that change rarely, so they get a week, with a rename-on-change rule. The service worker file and the version endpoint are the two things that must never be stale, so they are no-store.
const img = new Image();
img.fetchPriority = 'high';
img.src = url;
const show = () => {
if (!stale) {
displayedOverlayUrl = url; // swap only when the pixels are ready
}
};
img.decode().then(show, show); // on failure, show anyway: same broken state
That snippet is the fix behind one of this summer's measured wins. When a child picks a coloring page, the new line art is fetched and decoded off-screen, and the visible image swaps by opacity only once decode() resolves. The previous page stays visible meanwhile. On a physical iPad the landscape page swap went from 25 to 19 ms at P95, and its worst frame from 31 to 20 ms.
Page navigations try the network first and fall back to the cached shell after 5 s, so a stalled connection never leaves a child looking at a blank tab.
vite.config.tsOnly the first coloring book is precached. The others install book by book into versioned cache storage with per-file byte and SHA-256 checks, blocked on data-saver, cellular, or 2G, and paused when the tab is hidden.
manager.tsColoring fills ship at 1152 px on the long edge and thumbnails at 240 px, with responsive size tiers for the web and a single canonical file on native.
books.tsCover thumbnails warm at idle when the book opens, a book's pages warm on press, and the other orientation's art warms only after the picked page has decoded. Picking a page cancels every other in-flight warm so the chosen page gets the bandwidth.
imagePrefetch.tsAPI responses send Access-Control-Max-Age: 86400 so a browser asks permission once per day, not once per request.
A new service worker activates silently only when versions match, and the page reloads for it only when the tab is hidden and the canvas is blank. Checks run hourly and on focus; a cache-bust is attempted once per version.
updates.tsPutting it together
Here is everything above in the order a child experiences it. Each step names the habit at work, so you can see that the six are not six separate systems. They are one system, read from different angles.
Prerendered HTML with every stylesheet inlined. An inline script stamps the saved theme and layout onto the document before the first paint. The drawing engine boots from a side-effect import and is accepting strokes before the framework's 375 ms hydration task even starts. The save pipeline, the dialogs, the coloring-pack downloader, and the native plugins are in other files that have not been requested yet.
The pointer is captured. Its screen position maps to paper through a cached rectangle, not a layout query. The input canvas is 1 pixel wide, so the compositor pays nothing for it. A halo ring appears on a GPU-composited transform.
Every batched digitizer sample is read, so the curve is smooth, and pushed onto the pointer's queue. One animation frame is requested if none is booked. When it arrives, the queue drains into one render operation carrying every sample as its own curve segment.
Its padded bounding box is tested against the 20 tiles. Only intersecting tiles allocate a backing on first touch, capture a one-time undo snapshot, are un-hidden, and run the brush. The crayon re-glazes only the operation's rectangle on the web, or applies its glaze per op directly on the tile in the native app.
A pointer is down, so the idle scheduler refuses every deferred job: no dialog mounts, no history fold, no wax warm-up, no coloring-pack fetch, no font warm. The rotation hold, the resize settle timer, and the drawer's motion marker make sure a device rotation mid-stroke costs one layer transform and not a repaint.
The queue drains synchronously so no sample is lost. The command commits: each touched tile's snapshot is cropped to the dirty rectangle and joins a byte-budgeted history. If it was an eraser stroke, the "is the page blank now?" scan is scheduled for 400 ms of idle rather than run on the spot. On the third committed stroke of a first visit, the service worker is allowed to register.
After 300 ms of quiet and two clean frames, deferred work drains one slice at a time: the overlay chunk imports, the color picker mounts, the coloring book mounts, the oldest command outside the undo window folds into the raster base, the export compositor and its paper texture warm, and a coloring book downloads file by file. Any tap on a dialog jumps that queue and mounts it at once.
The screenshot module loads at press. The tiles are captured as transferable bitmaps at pointer-down, composed and encoded to PNG in a worker, and previewed from a worker-side downscale. A second tap inside 4 s is folded into the first so MobileSafari can reclaim its surfaces.
The picker shows raster selectors, not live SVG. The chosen page's line art fetches at high priority, decodes off-screen, and swaps in by opacity only when ready; every other in-flight warm is canceled so it gets the bandwidth. The picker itself retires on the compositor before the paper changes underneath it, and under a finger it has no backdrop blur to pay for.
Receipts
Every mechanism above was kept because a number moved. These are the before-and-after pairs recorded in the ADRs, issue threads, and scratchpad notes, on the device they were measured on.
Lower is better. The gold line is one 60 Hz frame (16.7 ms).
Share of in-contact time in late frames. The crayon's ship gate on iPad is 1.5%.
Costs that were once paid inside a frame, or nearly were, next to the two frame budgets.
Ranges are drawn as the measured span. Every bar longer than the frame it competes with is something the app now does at idle, in a worker, one slice per frame, or not at all.
Where it stands
The performance matrix captured on September 3, 2026 at product commit d17100cb. Each cell is the worst of four modes (portrait and landscape, light and dark) for that device and brush. Darker means more lost time; a red outline would mean a gate breach. There are none.
Gate: 1% (crayon on iPad: 1.5%). Source: the deployment-target matrix.
The native apps are noticeably cleaner than the same code in a browser tab. That gap is not the app's code; it is Safari and Chrome doing more per frame than a WKWebView or Android WebView does. Open the full matrix for every mode, the undo and action rows, and the provenance behind each cell.
How the numbers are earned
A performance number is only as honest as the recording behind it. The harness that produced the chart above refuses more captures than it scores, and the rules for refusing are the interesting part.
Input is injected at the OS level so events arrive with isTrusted: true. Fake JavaScript pointer events skip the machinery that makes input expensive, and once stayed perfectly smooth on a build where a real finger froze for 1.4 s.
A capture must deliver at least 0.9 pointer moves per observed frame, with a 95th-percentile gap under 25 ms. A robot that under-drives the screen produces fake, flattering numbers; a rate floor was tried first and rejected because it encoded the display's speed.
The beat is never assumed. Frame gaps are binned in half-millisecond buckets and the biggest bucket's median is the rhythm the display actually held. A fixed 16.67 ms assumption mis-scored every 120 Hz capture.
Numbers from different beats are not comparable: the same iPad cell read 1.3% at a 17 ms beat and 8.19% at an 8 ms beat, both computed correctly. Each target declares its regime, and a capture at the wrong one is refused and retried.
A late frame immediately followed by a short one, summing to two beats, is scheduler jitter around a steady display, not loss. 93% of an iPad's "late" frames were such pairs; genuinely lost Android frames pair 0% of the time.
Every published run lists, never scores, its trust dimensions: input fidelity, regime, repeats, plan, eraser refills, page identity, runtime, and host quiet, the last deliberately marked unrecorded until it can be measured.
The full terminology and the two validation grids live in docs/PERFORMANCE.md; the profiling runbooks are under docs/PROFILING*.md.
The graveyard
A performance page that only lists wins is a sales pitch. These are the mechanisms that looked right, were built, and lost to a number.
desynchronized: true)Passed every headless gate at 59.5 fps, then rendered the canvas opaque black on a real Android WebView: a hardware overlay plane does not alpha-composite with the DOM under it. ADR-0051.
A <link rel=preload> for the UI font was benchmarked against the idle document.fonts.load() warm and showed no win on the drawing route, which paints no text. ADR-0075.
Registering the offline cache on first paint saturated slow connections during the first strokes. Replaced by the three-stroke gate. ADR-0022.
Keeping both light and dark line art mounted and toggling opacity measured 0 ms but added a layer for no meaningful margin over the decode gate. ADR-0087, trial 22.
Caching the Magic sheet snapshot and the theme images across switches showed no improvement and was backed out. ADR-0087, trials 15 and 16.
A point-reduction pass on committed strokes made sense when history was replayed. The tiled per-frame-op architecture replays nothing, and the pipeline was deleted. ADR-0036, superseded by ADR-0066.
The original crayon design, two extra canvases per tile blended in CSS. On the native WKWebView the composited planes were the entire crayon cost. Retired on both targets; the elements remain hidden in the DOM. ADR-0147, ADR-0148.
Measured about 4× better on native than the planes, and rejected anyway: the color visibly shifted when the finger lifted. Appearance is a gate too. ADR-0147.
A 5,700-line spike whose own session recommended shipping it. Rejected: GPU cost tracked the render target's area rather than the drawn work (a scissor rectangle bought exactly 0 ms), and under Chromium's software fallback every variant was 2 to 3× slower than the canvas crayon, which therefore could never be retired. ADR-0153.
On the measurement side: an input-rate ceiling rejected a real fast hand at 178 to 268 moves per second, and the per-runtime coalescing check turned out to measure page delivery, not input. Both retired. ADR-0141, ADR-0144.
The full list
The complete inventory from docs/PERFORMANCE.md, re-checked entry by entry against the source at commit 3c01779. Entries whose description changed since the doc was written are marked revised; mechanisms the doc did not list yet are marked new. Every file link points at the verified commit, so the line numbers hold.
Keep beside the code
getCoalescedEvents().getBoundingClientRect() after a change forces one.requestAnimationFrame: run this once, right before the next frame. The loop only continues if the callback re-requests it.| File | Owns |
|---|---|
| strokeRasterQueue.ts | The per-pointer sample queue, the single frame request, the crayon granularity branch, and the merged-moves cap. |
| idle.ts | Both idle schedulers, the input-quiet tracking, and the double-frame busy check. |
| tiledRenderer.ts | Tile culling, hidden-tile lifecycle, patch undo, byte budget, idle folding, clear choreography, empty scan. |
| engine.ts | Pointer handling, coalesced replay, DPR cap, resize settle, re-entry resync, crayon deposition selection, export warm-up. |
| crayonPassBuffer.ts + crayonBrush.ts | The three deposition modes, restamp rectangles, wax-tile cache, warm-up deadline, pattern memos. |
| boot/bootHiddenOverlays.ts | The lazy overlay chunk, the one-per-slice mount pump, and the demand path that jumps the queue. |
| tests/startup-bundle.spec.ts | The test that pins the save pipeline and coloring-pack I/O outside the startup bundle. |
| pwa/updates.ts | Stroke-gated registration, the canvas-empty update lifecycle, and the hourly and focus checks. |
| docs/PERFORMANCE.md | The inventory this page was seeded from, plus the scoring terminology and validation grids. |