Performance explainer · verified against main on September 5, 2026

How Splotch keeps up with a toddler's finger

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.

16.7 ms per frame at 60 Hz ≤ 1% lost frame time to ship 101 mechanisms, each verified in the source 6 habits that explain almost all of them

Start here

Everything has to fit inside one frame

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.

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.

Main thread the worker

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.

requestAnimationFrame rAF

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.

Late frame a miss

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.

Lost frame time the score

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%.

Paint latency the feel

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.

Try it: spend a frame budget

Drag the slider to change how much work runs per frame. Watch when frames start to miss.

Display
16.7 msthe beat (time between frames)
0 of 12frames late
0%lost frame time (gate: ≤ 1%)

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".

What "fast" means here. Not a benchmark score. A physical iPad and a physical Android phone are driven by real, OS-injected touches through ten repeated gestures, and the app passes only if lost frame time stays under 1% and paint latency stays under the gates above. The scoring section explains how a capture earns the right to be scored at all.

The map

Six habits explain almost every mechanism

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.

Habit 1

Do it per frame, not per event

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.

Try it: samples versus frames

A 100 ms window of drawing on a 60 Hz screen. Change the digitizer rate and the painting strategy.

Digitizer
Paint
12pointer samples in 100 ms
12raster operations
2.0samples per painted frame

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.

web/src/lib/drawing/strokeRasterQueue.tsone frame request, one drain
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
  });
}

The same habit, elsewhere

rect

Never measure the canvas per move

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.ts
0 alloc

No allocation on the hot path

The 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.ts
halo

Pointer halos move once per frame

The 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.svelte
cap 8

Backpressure on merged crayon moves

If 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.ts
150 ms

Trailing resize rebuild

A 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.ts
1/rAF

Reveal one block per frame

The 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.svelte
A runtime exception, chosen on purpose. The crayon merges per frame in the native app but paints per move in Safari. Safari prices a pattern-filled stroke by its path length, so merging buys nothing there, while the native WKWebView prices per operation, so merging is a win. The choice is made at build time from the single CAPACITOR=true signal (ADR-0146).

Habit 2

Touch only what changed

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).

Try it: draw on the tiles

Drag on the paper. Only the tiles your stroke's padded box reaches light up.

tile that pays render + undo captureinkpadded bounding box
0 of 20tiles touched
100%of the paper skipped
0%of the paper copied for undo

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.

web/src/lib/drawing/tiledRenderer.tsthe hot decision: cull, back, snapshot, paint
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 is a pixel patch, not a replay

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.

20undo steps kept
6 ×paper-size byte budget for patches
1.5 sidle before one old command folds
103 → 17 msundo after a paper-restoring resize, once the extra repaint was skipped
web/src/lib/drawing/tiledRenderer.tsretention is a byte budget, not a count
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 same habit, elsewhere

Capped device pixel ratio

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
¼

Downscaled empty scan

"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.ts
2 rAF

Deferred hidden-tile clear

After 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.ts
1/rAF

Progressive clear capture

Clearing 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.ts
rect

Crayon restamps only the op's rectangle

On 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.ts
CSS

Animations scoped to visibility

The 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.

ClearCoachmark.svelte

Habit 3

Do it at idle, never under a finger

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.

Try it: hold the finger down

Press and hold the button to simulate a stroke. Deferred jobs wait; release and watch them drain.

Quiet for 0 ms/ 300 ms

Nothing is scheduled yet. The queue fills as soon as you touch the paper.

web/src/lib/idle.tsthe cooperative idle check
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
  });
});
300 msinput quiet before ordinary idle work
25 msmax gap between two frames to count as clear
750 msquiet, and spacing, for background dialog mounts
250 msretry delay when the check fails

What waits for idle

8

Boot-hidden overlays

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.ts
400 ms

Eraser empty scan

After 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.ts
wax

Crayon textures, prebuilt and warmed

The 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.ts
SW

Service worker after three strokes

The 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.svelte
226 ms

Export warm-up

The save compositor and its paper texture (a 226 ms fetch) warm at idle so the first tap on the camera does not stall.

engine.ts
📚

Coloring packs, file by file

Extra 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.ts

Habit 4

Keep it off the startup path

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.

HTML + inline CSS
prerendered
State stamp
inline
Drawing engine
boots
accepting strokes
Hydration
~375 ms long task
Idle work
overlays chunk
fonts
packs…
Service worker
after 3 strokes
page requestedfirst stroke possiblelater
  1. HTML arrives already rendered, with every stylesheet inlined in its head.
  2. The state stamp runs inline and paints the saved theme and layout before anything else.
  3. The drawing engine boots and starts accepting strokes.
  4. Hydration runs its ~375 ms task while the engine keeps drawing underneath it.
  5. Idle work begins only after that: the overlay chunk, fonts, coloring packs.
  6. The service worker registers after the third committed stroke.

Not to scale. The point is the ordering: the engine is live before hydration starts, and the idle work only begins after it ends.

web/src/lib/drawing/engine.tsthe component adopts an engine that is already running
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 };
}

What is in the startup bundle, and what is not

Startup bundle

earlyBootenginetiledRendererstrokeRasterQueuecrayonBrushidlestorage sync readsDrawingCanvasActionsPanel

Loaded later, on demand or at idle

screenshot + exportDrawing at pressfolderSave at pressoverlay chunk 8 dialogs, idlecoloringPacks/manager idleidb first durable write@capacitor/* plugins native onlymagicSheet.workerpngEncoder.workerErrorScreen only on crashParentalGate privacy page
The boundary is a test, not a convention. 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.
web/src/lib/components/ActionsPanel.sveltea memoized import that forgets a failure
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;
}

The same habit, elsewhere

html

Pre-hydration state stamp

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.

app.html
css

All CSS inlined

inlineStyleThreshold: Infinity puts every stylesheet into the prerendered head, which ended a flash of unstyled content on iPadOS.

svelte.config.js
tgt

Build target is the real browser floor

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.ts

Instrumentation compiled away

Profiling 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.

defines.ts
sync

Settings read synchronously

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.ts

Single-flight helpers

Overlapping 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.ts

Habit 5

Move heavy work off the main thread

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.

Give it to the compositor

  • Rotation never repaints ink. The tile grid is presented through one CSS transform on a permanently promoted layer (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 tiles are not layers. An empty tile is hidden, so the compositor never stacks it. Un-hiding dozens of transparent canvases used to cost 40 to 50 ms on every theme switch.
  • Animated overlays are promoted. The polaroid, confetti, progress dial, clear button, and color sheet carry will-change or contain so their motion does not invalidate the page around them.
  • Halos move by transform. Each pointer's ring moves with translate3d, which the compositor animates without a layout or paint.

Give it to a worker

  • PNG export. Saving a drawing captures each tile as a transferable 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".
  • Magic brush sheets. The color sheet the Magic brush reveals is fetched, decoded, and rasterized in a worker with a 15 s timeout and a main-thread fallback.
  • Both workers are cached singletons that recreate themselves on failure, so a crashed worker costs one retry, not a permanent fallback.
web/src/lib/components/LiveSurface.sveltea comment that a test enforces
.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: three ways to deposit wax, two of them shipping

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.

web/src/lib/drawing/engine.tsone build-time signal picks the pipeline
configureCrayonDeposition(
  __IS_CAPACITOR__ ? 'glaze-direct' : 'restamp',
  () => activePointers.size > 0
);

The same habit, elsewhere

LRU

Bounded wax-tile cache

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.ts
pat

Pattern caches everywhere

Canvas 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.ts
edge

Never copy a surface onto itself

Extending 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.ts
4 s

Screenshot cooldown

After 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.ts
webp

WebP for AI uploads

The 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.ts
🎵

Procedural clear sound

The 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.ts

Habit 6

Never let the network hold a frame

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.

How long the edge is allowed to keep each file

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.

The same habit, elsewhere

5 s

Network first, but not forever

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.ts
📖

One starter book, the rest in the background

Only 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.ts
1152

Capped image resolutions

Coloring 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.ts
warm

Tiered prefetch with cancellation

Cover 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.ts
24 h

Preflight cached a day

API responses send Access-Control-Max-Age: 86400 so a browser asks permission once per day, not once per request.

hooks.server.ts
🔄

Updates that never interrupt a drawing

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.ts

Putting it together

One stroke, start to finish

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.

  1. 4 · startup

    The page arrives

    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.

  2. 1 · per frame

    A finger touches the glass

    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.

  3. 1 · per frame

    The finger moves

    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.

  4. 2 · only what changed

    The operation is painted

    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.

  5. 3 · at idle

    Meanwhile, everything else waits

    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.

  6. 2 · only what changed

    The finger lifts

    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.

  7. 3 · at idle

    The quiet after

    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.

  8. 5 · off main thread

    The camera button

    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.

  9. 6 · network

    A coloring page is picked

    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.

The test at the end. On the physical iPad and the physical Android phone, with real injected touches through ten repeated gestures, lost frame time stays under 1% for every brush, and paint latency stays under 20 ms at the 95th percentile. The heatmap below is that claim, cell by cell.

Receipts

The measured wins

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.

Before and after, in milliseconds

Lower is better. The gold line is one 60 Hz frame (16.7 ms).

beforeafterone frame at 60 Hz

Crayon lost frame time on the physical iPad

Share of in-contact time in late frames. The crayon's ship gate on iPad is 1.5%.

beforeafter1.5% gate

How big is a millisecond?

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

Lost frame time on the physical devices, today

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.

Worst-mode lost frame time, by device and brush

Gate: 1% (crayon on iPad: 1.5%). Source: the deployment-target matrix.

15–16 mspaint latency P95, iPad web and native (60 Hz)
7.4–8 mspaint latency P95, Android web and native (120 Hz)
0.83%worst physical cell (Magic, Android web)
0.00%most native cells

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 capture has to prove itself before it is scored

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.

Trusted touch

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.

Density floor

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.

Dominant interval

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.

Refresh regime

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.

Pair credit

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.

Trust ledger

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

Tried, measured, and taken out

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.

Low-latency canvas (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.

Font preload link

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.

Service worker at load

Registering the offline cache on first paint saturated slow connections during the first strokes. Replaced by the three-stroke gate. ADR-0022.

Two resident theme images

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.

Sheet and theme-image snapshot caching

Caching the Magic sheet snapshot and the theme images across switches showed no improvement and was backed out. ADR-0087, trials 15 and 16.

Stroke simplification at commit

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.

CSS-blended crayon preview planes

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.

A "deferred stamp" crayon pipeline

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 WebGL crayon renderer

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.

Cadence ceilings and coalescing checks

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

Every mechanism, sorted by habit

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

Jargon decoder

Beat
The steady gap between frames the display holds: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. Measured from each capture, never assumed.
Bounding box
The smallest rectangle that could contain an operation's ink, padded for stroke width and anti-aliasing. Drives culling and undo cropping.
Bundle / chunk
One JavaScript file the browser must download and parse before running it. The startup bundle is what the first frame needs.
Coalesced events
The digitizer samples the browser batched between two pointer events, exposed by getCoalescedEvents().
Compositor
The browser stage that stacks painted layers into the final picture, usually on the GPU. Transforms, opacity, and blends can run there without repainting.
Culling
Skipping tiles whose bounds cannot intersect an operation.
Device pixel ratio (DPR)
Physical pixels per CSS pixel. Splotch renders at min(DPR, 2).
Dirty rectangle
The smallest conservative pixel box a command may have changed.
Fold
Rasterize an old, no-longer-undoable command into the offscreen raster base so its vectors can be dropped.
Hydration
The framework attaching behavior to prerendered HTML. About 375 ms of main-thread work on the iPad.
Idle
A stretch where the main thread has nothing urgent. Splotch's definition: no pointer down, input quiet, two clean frames.
ImageBitmap / OffscreenCanvas
A decoded bitmap that can be transferred to a worker, and a canvas that lives there.
Layout (reflow)
The browser recomputing element positions. Reading getBoundingClientRect() after a change forces one.
Lost frame time
Share of in-contact time spent inside late frames. Gate: 1%.
Main thread
The single lane running JavaScript, style, layout, and paint. A busy lane means a late frame.
P95 / P99
The value 95% (or 99%) of samples fall under. A P95 of 20 ms means one frame in twenty was slower.
Paint latency
Finger movement to the frame that showed its ink.
Patch
A cropped pre-command pixel snapshot used to reverse the newest command.
Precache
Files the service worker downloads on install so they open offline: the app shell and the starter book only.
rAF
requestAnimationFrame: run this once, right before the next frame. The loop only continues if the callback re-requests it.
Readback
Copying pixels from the GPU back to JavaScript. Slow, and it can push a canvas off the GPU permanently.
Service worker
A background script that intercepts the page's network requests and can answer from a cache.
Single flight
Overlapping callers share one in-flight promise instead of starting duplicate work.
Tile
One cell in the 4 × 5 grid of live canvases that together present the paper.
will-change
A CSS hint that an element will animate a property, so the browser gives it its own compositor layer up front.
Worker
A JavaScript thread separate from the main one. No DOM access, but it can own an OffscreenCanvas.
WKWebView / WebView
The embedded browser engine inside the iOS and Android native apps. Same code, different compositor behavior from Safari and Chrome.

Where to read the production implementation

FileOwns
strokeRasterQueue.tsThe per-pointer sample queue, the single frame request, the crayon granularity branch, and the merged-moves cap.
idle.tsBoth idle schedulers, the input-quiet tracking, and the double-frame busy check.
tiledRenderer.tsTile culling, hidden-tile lifecycle, patch undo, byte budget, idle folding, clear choreography, empty scan.
engine.tsPointer handling, coalesced replay, DPR cap, resize settle, re-entry resync, crayon deposition selection, export warm-up.
crayonPassBuffer.ts + crayonBrush.tsThe three deposition modes, restamp rectangles, wax-tile cache, warm-up deadline, pattern memos.
boot/bootHiddenOverlays.tsThe lazy overlay chunk, the one-per-slice mount pump, and the demand path that jumps the queue.
tests/startup-bundle.spec.tsThe test that pins the save pipeline and coloring-pack I/O outside the startup bundle.
pwa/updates.tsStroke-gated registration, the canvas-empty update lifecycle, and the hourly and focus checks.
docs/PERFORMANCE.mdThe inventory this page was seeded from, plus the scoring terminology and validation grids.

Architectural records behind this page