{"slug": "three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser", "title": "Three things that bit us moving a document scanner fully into the browser", "summary": "A developer on the LensUp team documented three browser-specific pitfalls encountered while moving a document scanner's entire image-to-PDF pipeline into the client, with no server upload. The main issue: navigator.share() requires transient user activation that expires during long PDF renders on mid-range phones, causing NotAllowedError failures; the team worked around it by decoupling PDF preparation from sharing and checking navigator.userActivation.isActive to fall back to an honest two-tap flow. The post also advises feature-detecting the file variant via navigator.canShare with a real File object rather than checking 'share' in navigator, and treating AbortError from a dismissed share sheet as a normal user action rather than a failure.", "body_md": "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.\n\nI work on [LensUp](https://lensup.ai/), 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.\n\nThe 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:\n\n``` js\nshareButton.addEventListener('click', async () => {\n  const bytes = await buildPdf(pages);           // takes a while\n  const file = new File([bytes], 'scan.pdf', { type: 'application/pdf' });\n  await navigator.share({ files: [file] });      // 💥 NotAllowedError\n});\n```\n\n`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.\n\nThere 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:\n\n``` js\nlet preparedShare = null;  // { file, identity }\n\nshareButton.addEventListener('click', async () => {\n  let file;\n  if (preparedShare && sameOutputIdentity(preparedShare.identity, snapshotOutputIdentity())) {\n    file = preparedShare.file;                   // second tap: no await before share()\n  } else {\n    preparedShare = null;\n    const { pages, settings } = await prepareCurrentOutputPages('share');\n    const bytes = await buildPdf(pages, settings);\n    file = new File([bytes], `scan-${Date.now()}.pdf`, { type: 'application/pdf' });\n    preparedShare = { file, identity: settings.identity };\n\n    // A long render may outlive transient user activation. The next tap shares\n    // the prepared file synchronously, only while its content/settings still match.\n    if (navigator.userActivation?.isActive === false) {\n      toast('Your file is ready — tap share again');\n      return;\n    }\n  }\n  await navigator.share({ files: [file], title, text });\n  preparedShare = null;\n});\n```\n\n`navigator.userActivation.isActive` is the part worth knowing about. It lets you tell the difference between \"this will work\" and \"this will throw\", so you can degrade to an honest two-tap flow instead of showing an error for something the user did nothing wrong in. On a fast device the second tap never happens; on a slow one the user gets a clear \"ready, tap again\" instead of a failure.\n\nTwo details that go with it.\n\n**Feature-detect the file variant, not the API.** `'share' in navigator` tells you nothing about whether *files* can be shared — that support is separate, and it varies. The only honest probe is to build a real `File` of the type you intend to send and ask:\n\n``` js\nconst canShareFiles = (() => {\n  try {\n    const f = new File([new Uint8Array([37, 80, 68, 70])], 't.pdf', { type: 'application/pdf' });\n    return !!(navigator.canShare && navigator.canShare({ files: [f] }));\n  } catch {\n    return false;\n  }\n})();\nif (!canShareFiles) shareButton.hidden = true;\n```\n\nThose four bytes are `%PDF`. Building the probe file from the real MIME type matters, because `canShare` can accept one type and refuse another.\n\n**`AbortError` is not an error.** When the user opens the share sheet and dismisses it, `share()` rejects with `AbortError`. If you surface that as a toast, you are telling people something failed when they simply changed their mind:\n\n```\n} catch (err) {\n  if (!err || err.name !== 'AbortError') {\n    showShareFailed(err);\n  }\n}\n```\n\nFinding the page corners in a photo — the geometry that turns a trapezoid back into a rectangle — is the one genuinely CPU-heavy step, and it has no business on the main thread while someone is trying to scroll.\n\nThe obvious architecture is a long-lived worker (or a small pool) plus request IDs, so you can match a response to the request that asked for it. We ended up with the opposite: **spawn a worker for one image, then terminate it.**\n\n```\nexport async function detectQuadFromBlob(blob, options = {}) {\n  if (!blob || options.signal?.aborted) return null;\n\n  // A worker owns only this image. Completion, cancellation and timeout all release\n  // its bitmap/heap; older requests cannot deliver a later request's result.\n  if (typeof window !== 'undefined'\n      && typeof Worker === 'function'\n      && typeof OffscreenCanvas === 'function') {\n    let worker;\n    try {\n      worker = new Worker(new URL('../workers/quad-detect-worker.js', import.meta.url),\n                          { type: 'module' });\n    } catch {\n      /* CSP / unsupported module worker: fall through to the local path. */\n    }\n    if (worker) return new Promise(resolve => {\n      const { signal, ...settings } = options;\n      let done = false;\n      const finish = value => {\n        if (done) return;\n        done = true;\n        clearTimeout(timer);\n        signal?.removeEventListener('abort', abort);\n        worker.terminate();\n        resolve(value);\n      };\n      const abort = () => finish(null);\n      const timer = setTimeout(abort, 10000);\n      const fallback = () => { if (!done) finish(detectLocal(blob, options)); };\n\n      worker.onmessage = e => finish(e.data?.detection ?? null);\n      worker.onerror = fallback;\n      worker.onmessageerror = fallback;\n      signal?.addEventListener('abort', abort, { once: true });\n      if (signal?.aborted) { abort(); return; }\n      try { worker.postMessage({ blob, options: settings }); } catch { fallback(); }\n    });\n  }\n  return detectLocal(blob, options);\n}\n```\n\nThree reasons this turned out better for this particular job:\n\n**Stale results become structurally impossible.** With a shared worker, a response from the image the user already replaced can arrive after you've moved on, and you are one forgotten ID comparison away from cropping photo B by photo A's corners. Terminating the worker deletes that bug class instead of guarding against it.\n\n**Memory releases deterministically.** A decoded `ImageBitmap` from a 12-megapixel photo is tens of megabytes. `terminate()` takes the whole worker heap with it, which is a much shorter argument than reasoning about when the bitmap becomes unreachable inside a worker that keeps running. The worker side still closes it explicitly, because the tab may be doing several things at once:\n\n``` js\nself.onmessage = async ({ data }) => {\n  let bitmap;\n  try {\n    bitmap = await createImageBitmap(data.blob);\n    self.postMessage({ detection: detectQuadFromSource(bitmap, data.options) });\n  } catch {\n    // The importer keeps the full original image on every detection failure.\n    self.postMessage({ detection: null });\n  } finally {\n    bitmap?.close();\n  }\n};\n```\n\n**Cancellation is just `terminate()`.** No cooperative abort checks inside the detection loop, no message protocol for \"never mind\".\n\nThe cost is real — you pay worker startup per image, and on a cold module worker that is not free. For a user importing a handful of pages, that cost is invisible; if you were detecting corners on a video stream at 30fps, you would want the pool and the request IDs.\n\nNote what the failure path does: every error resolves to `null`, and `null` means **keep the full original image**. A scanner that crops wrong is worse than a scanner that doesn't crop, so the degraded state is \"you get your whole photo\" rather than \"you get two thirds of your passport\".\n\nGovernment portals and university systems love a hard byte ceiling: PDF, under 200 KB, colour, A4. Developers see that requirement and go looking for the quality parameter that produces 200 KB.\n\nThere isn't one. The size of an encoded JPEG is a function of the image content as much as the quality setting — a dense page of small text and a mostly-white form at the same quality can differ several-fold. The only thing you can do is encode, measure, and step:\n\n``` js\nasync function encodeTowardTarget(canvas, targetBytes) {\n  let best = null;\n  for (const q of [0.92, 0.85, 0.78, 0.7, 0.6, 0.5, 0.42, 0.35]) {\n    const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', q));\n    best = blob;\n    if (blob.size <= targetBytes) break;   // first one that fits wins\n  }\n  return best;                              // may still exceed the target\n}\n```\n\nTwo things follow from that, and both are product decisions rather than technical ones.\n\nThe loop has to terminate somewhere, which means **the result can still be over the ceiling**. You either keep degrading until the page is unreadable, or you stop and hand back something too big. We stop, which makes this a best-effort operation — and if you are building something similar, say that in your UI. Telling a user you will hit an exact byte count is a promise the format does not let you keep.\n\nAnd if you are the one filling in the form: check the file, don't trust the label. A tool that claims an exact size is either lying or about to destroy your document's legibility.\n\nFor all three of the above, the reward is worth it. The documents people scan are passports, signed contracts, medical forms, payslips — the most sensitive paper most people own. Doing the work in the tab means the server only ever ships HTML, JavaScript and translations; it never receives a pixel of the document.\n\nThat claim is also checkable, which is the main thing I'd push for in this category: open DevTools → Network, clear the log, scan something, export it, and watch whether your file's bytes leave. If a \"client-side\" claim is real, the Network tab shows it. If it isn't, you'll see a POST with your document in it. Worth doing to any tool that touches your paperwork — including ours.\n\nIf you want to poke at the implementation described above, it's running at [a browser-based document scanner](https://lensup.ai/cam-scanner/).", "url": "https://wpnews.pro/news/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser", "canonical_source": "https://dev.to/yanwang/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser-15cc", "published_at": "2026-09-13 03:55:14+00:00", "updated_at": "2026-09-13 04:26:21.913696+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["LensUp", "Web Share API", "navigator.share", "navigator.canShare", "navigator.userActivation"], "alternates": {"html": "https://wpnews.pro/news/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser", "markdown": "https://wpnews.pro/news/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser.md", "text": "https://wpnews.pro/news/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser.txt", "jsonld": "https://wpnews.pro/news/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser.jsonld"}}