From seven seconds to 226 milliseconds: document previews under end-to-end encryption
How Tula opens Word documents, slide decks and spreadsheets entirely on your phone — because our servers can't read them, and never will.
Most messaging apps preview your documents in the cloud: you send a file, a server opens it, renders it, and hands your phone a picture. Tula can't do that, by design. Every message and every attachment is end-to-end encrypted — the moment a document leaves your device it is ciphertext, and no server of ours may ever hold the plaintext. If we want you to read a document inside a chat, there is exactly one place the conversion can happen: on the sender's phone, before encryption.
That single constraint has shaped one of the most interesting engineering problems we've worked on.
Version one: standing on mammoth.js
We didn't start from zero. Our first converter began life as mammoth.js, Michael Williamson's excellent docx-to-HTML library. We vendored it and rewrote it to fit our world: Hermes (React Native's JavaScript engine, which lacks half the web APIs libraries assume), and an output format of our own — not HTML, but DocumentIR, a compact structured description of the document (paragraphs, headings, tables, fonts, comments) that travels encrypted inside the message and renders natively on the recipient's screen. That rewrite became rn-docx-ir, and it shipped.
It worked. But the phones our users actually carry are not flagship phones. On a Galaxy A05s, conversion cost roughly three milliseconds per kilobyte — a 130-page thesis took almost seven seconds of the app's JavaScript thread, the same thread that draws the interface. And it only spoke .docx. A .doc from 2003, a slide deck, a spreadsheet — all of them fell back to an opaque file card.
Then, one evening on X
Honestly: the next chapter started with doomscrolling. A post by Nick Camara introduced anydoc — an MIT-licensed document conversion engine in Rust by the Firecrawl team that already read ten document formats. It was built to feed LLMs, so it outputs Markdown: text without presentation. Chat previews need more — fonts, sizes, colors, highlights, table borders, authored column widths, comments.
So instead of building a second converter from scratch, we forked it and taught it presentation. The fork carries formatting through the whole pipeline — including the strange, wonderful corners of the Word format, like toggle properties, where bold applied by a style chain cancels out an even number of times (yes, really; it's ECMA-376 §17.7.3). We kept one hard rule: the fork's Markdown output stays byte-identical to upstream, verified by the original snapshot suite on every change. anydoc is a fast-moving project and we intend to keep merging — that rule is what keeps upstream's pace a gift rather than a burden.
The Rust core runs behind a Nitro module, off the UI thread. The document's bytes go in; one buffer comes back, the extracted images inside it crossing the bridge zero-copy. The garbage collector's cleanup callback is the Rust deallocator — one allocation makes the whole journey.
The measurement that humbled us
Our first in-app benchmark said the thesis took 6.9 seconds even with the Rust core — barely better than JavaScript. We had a theory: decoding half a megabyte of result JSON in JavaScript was the bottleneck. We rewrote the bridge so the JSON crosses as a native string. The result: no change at all.
The real culprit, found by instrumenting the native side: the benchmark ran during app startup, and the answer — computed in a few hundred milliseconds — sat in a queue behind the app's own boot work. Measured fairly, with the app idle, the thesis converts end-to-end in 226 milliseconds: 949 kilobytes, 972 blocks, 16 embedded images, all 72 of its review comments. The pure-native benchmark for the same file is 233 milliseconds — the bridge overhead had vanished into the noise.
The JSON-as-string rewrite still earned its place; it deleted an entire hand-written UTF-8 decoder. But the lesson is older than our codebase: profile before you optimize, and be suspicious when an optimization changes nothing.
Trust, but verify: the A/B
Speed means nothing if the output is wrong, so before switching our primary format over, we ran both converters — rn-docx-ir and the Rust fork — over a 30-document corpus, on the phone, and compared everything: a hash of every character of extracted text, counts of every block, run, style, table property and comment.
The results:
- On every Word-produced document, the extracted text was hash-identical.
- The Rust converter resolved fonts the JavaScript one missed, and extracted comments it couldn't.
- Four documents only the Rust path could preview at all — including that thesis, which the JavaScript converter's size guard had always refused.
- The whole corpus: 8.9 seconds in JavaScript, 0.3 seconds in Rust.
The A/B also caught two things worth their weight. A pair of "text differences" turned out to be our own hand-built test fixtures omitting xml:space="preserve" — the Rust converter was matching what Word actually does with such files; our JavaScript converter had been quietly more lenient than Word itself. And one real bug: documents saved in Word's Strict OOXML mode keep their font size on the default paragraph style, which unstyled runs weren't inheriting. Found on a Tuesday, fixed the same day, verified in the next run.
Never lose a document
The pipeline that ships is a ladder, and every rung is a working product:
- The Rust converter — every format, native speed.
- If it can't deliver (or the native library isn't there, as on some emulators): rn-docx-ir, exactly what shipped before. Our first converter didn't retire; it became the safety net.
- If nothing can produce a faithful preview: the file sends anyway, intact, and opens in any external app.
A conversion failure is a slightly worse card in the chat. It is never a lost document, and it never blocks a send.
What you get
Documents that used to be opaque attachments — legacy .doc files, PowerPoint decks, Excel sheets, OpenDocument files, RTF, EPUB — now open as real, readable, styled documents inside the chat, on the device, in milliseconds. Comments your supervisor left in the margins arrive with the document. And none of it ever touches a server as anything but ciphertext.
The fork is public at github.com/tulaafrica/anydoc, and we're publishing the React Native package as react-native-anydoc so any app can use the same engine. It's MIT licensed — we stood on mammoth.js's shoulders, then on Firecrawl's, and we'd like others to stand on ours. Upstream anydoc is evolving quickly and we'll do our best to keep up with it; the byte-identical-Markdown rule exists precisely so we can.
Numbers in this post were measured on a Samsung Galaxy A05s — deliberately, because that's the phone most of our users have.



