Three things that bit us moving a document scanner fully into the browser
Photographing a page and turning it into a clean, printable PDF is, on paper, a solved problem: read a file, fix the perspective on a canvas, flatten the shading, write a PDF. Browsers have shipped every primitive for that for years, and none of it needs a server. I work on LensUp , which does exactly that — the whole pipeline runs in the tab, and files are never uploaded. Disclosure up front: it's our tool, and this post is about the parts that were harder than the pipeline itself. None of the three below are in the "how to draw an image to a canvas" tutorials, and all three cost us real debugging time. 1. Web Share can be gone by the time your file is ready The Web Share API's file variant is the nicest way to hand a generated PDF to whatever the user actually wants to do with it. The naive version looks fine: shareButton . addEventListener ( ' click ' , async () => { const bytes = await buildPdf ( pages ); // takes a while const file = new File ([ bytes ], ' scan.pdf ' , { type : ' application/pdf ' }); await navigator . share ({ files : [ file ] }); // 💥 NotAllowedError }); navigator.share() requires transient user activation , and transient activation expires. Building a multi-page PDF from full-resolution photos on a mid-range phone takes long enough that by the time you call share() , the activation from the tap is gone. You get a NotAllowedError , the share sheet never opens, and it only reproduces on slow devices — which is the worst possible failure profile. There is no way to extend the activation. What you can do is decouple "prepare" from "share", and check whether you still have activation before deciding which one you're doing: let preparedShare = null ; // { file, identity } shareButton . addEventListener ( ' click ' , async () => { let file ; if ( preparedShare && sameOutputIdentity ( preparedShare . identity , snapshotOutputIdentity ())) { file = preparedShare . file ; // second tap: no await before share() } else { preparedShare = null ; const { p