Text With No Spaces, a Race Condition, and Stuttering Animations: 4 Real Bugs From an Encrypted Notes PWA

javascript dev.to

Building advanced features for a zero-knowledge encrypted notes PWA, I ran into four problems you rarely read about in tutorials: a PDF with no space between words, a race condition that deleted images that had already been saved, animations that only stuttered on iOS, and a manifest field that blocked screen rotation only on Android.

None of the four behaved the way I expected going in. Here's what caused each one, and how I fixed them.

The context

The app is a client-side encrypted notes tool: zero-knowledge, AES-256-GCM encryption before every write, installable as a PWA on iPad and Android. One of the features added was automatic document import — upload the PDF program of an event (schedules, speakers, sessions across one or more days) and have one or more already-structured notes generated automatically, complete with a cover image pulled from the first page.

Sounds like trivial parsing. It isn't, once you open the actual PDF.

Problem 1: the PDF has no spaces between words

The first text-extraction test returned something like:

10:00Thespeakeropenswithaquestion
Enter fullscreen mode Exit fullscreen mode

Not a parser bug — the text inside that PDF is genuinely stored that way. A PDF doesn't contain "sentences," it contains glyphs positioned one by one on the page with precise x/y coordinates. Many typesetting engines place each letter with specific kerning and never insert an explicit space character into the stream. The gap between words is purely a visual effect of the layout, not a piece of data. Libraries like PDF.js or pdfplumber return exactly what's there: characters or text "items," not pre-separated words.

The fix: reconstruct spaces by comparing distances. Measure the horizontal gap between the end of one character and the start of the next:

  • Inside a word (kerning) → gap stays under a few percent of the font size
  • Between two words → gap is roughly a quarter of the font size

A threshold at around 10% of the font size separates the two cases reliably.

The detail that broke my first attempt: a fixed-pixel threshold works for body text but breaks on cover titles, which often use huge fonts with intentional letter-spacing — the same pixel gap that signals a space at 9pt is tiny next to a 34pt font. Making the threshold proportional to font size, not an absolute value, fixes both cases with the same formula.

Two extra sources of noise to filter: typographic ligatures (fi, ffi) often arrive as a single Unicode glyph and need normalizing back into two letters, and some accented letters get rendered as a base letter plus a separate diacritic mark on a slightly different row — producing a "phantom" line made of a single isolated accent that needs discarding before reconstructing the text.

Problem 2: a race condition deleted images that had already been saved

Images pasted into a note are encrypted client-side before landing in Storage. Opening a note fetches and decrypts each image asynchronously; while that's in progress, a loading placeholder ("Decrypting image…") stands in for the future <img> tag.

If the user hit Save while that placeholder was still on screen — slow connection, a too-quick tap — the save logic (which determines which images to keep by scanning the DOM for actual <img data-img-id> tags) no longer found that image. The placeholder span isn't an <img>, so it got silently treated as "removed by the user" — and genuinely deleted from Storage. No console error, because technically nothing failed: the code did exactly what it was written to do, on the wrong premise.

The fix: an explicit guard. Before saving, check whether a decrypting placeholder is still present in the DOM and, if so, block the save with a warning instead of letting the scan misread a transient state.

The broader lesson: inferring user intent by diffing the DOM before/after breaks silently the moment any part of that DOM can be in a transient state — a placeholder, a spinner, anything not yet final — because the diff can't tell "removed" from "not finished loading yet."

Problem 3: animations that stuttered only on iOS Safari

Opening a note or expanding a sidebar folder had a small fade animation. Smooth on desktop and Android; visibly stuttering on iPad. Isolating one variable at a time turned up three overlapping causes:

  1. Background blur. Both panels use backdrop-filter: blur() for a glass effect. Blur is one of the most expensive CSS filters to recompute, and Safari redoes it every frame if the blurred element changes during a transition. Fix: isolate each blurred panel in its own compositing layer with transform: translateZ(0), so the blur gets computed once instead of every frame.
  2. Content written mid-animation. The opening animation started before the note's title/body (fetched and decrypted asynchronously) had been written into the DOM — so a chunk of content sometimes landed exactly mid-transition. Fix: reorder the flow — populate the DOM first, then trigger the animation on an already-stable element.
  3. No advance notice to the renderer. Without will-change declared ahead of time, Safari had to build the GPU layer at the exact moment the transition started instead of preparing it beforehand.

requestIdleCallback is not a reliable substitute for "wait until the animation is actually done" — it fires whenever the main thread looks idle, which can happen mid-transition. A fixed timeout matched to the CSS transition's real duration was more predictable here.

Problem 4: a manifest field that locked rotation only on Android

After installing the PWA, rotating an Android tablet to landscape did nothing — the app stayed locked in portrait. The exact same app installed on an iPad rotated freely. No orientation-based media query existed in the CSS, so it wasn't styling.

It was one line already sitting in the manifest:

"orientation":"portrait-primary"
Enter fullscreen mode Exit fullscreen mode

Chrome on Android strictly enforces the manifest's orientation field for standalone PWAs. Safari on iOS, at least in the version tested, doesn't apply that field to installed web apps at all — so the exact same file produced opposite behavior on the two platforms, with zero platform-specific code written for either one.

The fix: change the value to "orientation": "any" (or remove the field). One caveat: on Android, an already-installed PWA doesn't necessarily pick up a manifest change right away — sometimes it takes uninstalling/reinstalling, or waiting for the browser's periodic manifest refresh.

What I take away from this

  • A PDF that wasn't designed for copy-paste needs to be treated as geometric data, not text — a font-size-proportional gap threshold beats any fixed value.
  • Any logic that infers user intent by diffing the DOM needs an explicit guard against transient states, or it will eventually delete something by mistake.
  • Glass effects and animation don't mix for free on iOS Safari — isolate the compositing layer, and never write heavy content mid-transition.
  • A PWA manifest field can behave completely differently across platforms. Test on both before assuming a config choice is safe everywhere.

Originally published on my site, with more detail and a working FAQ: roversia.it

Source: dev.to

arrow_back Back to Tutorials