DEV Community

Cover image for Running Pandoc in the Browser: WebAssembly, Web Workers, and a 58 MB Module
yixiang xiao
yixiang xiao

Posted on

Running Pandoc in the Browser: WebAssembly, Web Workers, and a 58 MB Module

Every "convert my document" tool I've tried has the same shape: you pick a file, it uploads somewhere, a server does the work, you get a download link back. That's fine for public data. It's a much worse deal when the file is a contract, a manuscript, a medical record, or anything else you'd rather not hand to a stranger's S3 bucket.

So I built a Word→Markdown converter that never uploads anything. It runs the real Pandoc engine, compiled to WebAssembly, inside a browser tab. Here's what that actually took.

Why Pandoc, and not an HTML pipeline

The obvious approach for .docx → Markdown is to parse the OOXML into HTML and then walk the HTML into Markdown. That's what most library-based solutions do, and it works for simple documents.

It falls apart on the things that make Word documents Word documents: footnotes, cross-references, citation fields, equation markup, tracked formatting. By the time you've flattened all of that to HTML, the semantics are gone — you're walking a DOM that no longer knows what a footnote is.

Pandoc takes a different route. It parses each format into a typed abstract syntax tree, then writes that AST back out in whatever target format you ask for. Nothing gets flattened prematurely. That means:

  • Footnotes stay footnotes, with their original numbering
  • Citations survive as citation AST nodes (and can be processed by a CSL processor)
  • Math survives as math, not as a <span> with a font-family
  • One engine covers the whole matrix. The same binary reads docx, odt, epub, html, latex, rst, org, csv, tsv, mediawiki, docbook, opml, ipynb and Markdown, and writes Markdown, HTML, reStructuredText and LaTeX

The last point is the real argument. I don't maintain fourteen readers and four writers — I maintain one integration against one very good engine.

Compiling Pandoc to WebAssembly

Pandoc is a Haskell program. The official WASM build exists, and it works, and it is large — around 58 MB once you include the engine and the data files it needs.

58 MB is a non-starter if you load it on page load. Nobody is going to sit through that to look at a tool. So:

Load it lazily, on intent. The module starts downloading when the user actually drops a file or hits convert — not when the page renders. The landing page stays instant; the cost is paid by people who are already committed.

Run it in a Web Worker. Compiling and executing 58 MB of WASM on the main thread will lock the UI solid. In a worker, the page stays responsive and the user can keep scrolling, reading, or cancel.

The part everyone underestimates: compilation, not download

Here's the thing I got wrong at first. I assumed the network transfer would be the bottleneck. It isn't. Downloading 58 MB is a few seconds on a decent connection. Compiling it is the slow part — WebAssembly.compile() on a module that size takes real time, every time, on every page load.

The fix is to cache the compiled artifact, not the bytes. WebAssembly.Module is structured-cloneable, so you can hand it to IndexedDB and get it back later without recompiling:

  1. IndexedDB — look up the previously compiled WebAssembly.Module and instantiate it directly. No download, no compile.
  2. Cache API — if IDB has nothing, read the raw bytes from the HTTP cache and compile.
  3. Streaming download — last resort, fetch and stream-compile.

The load order is exactly that chain, and the first conversion is the only one that pays full price. Every conversion after that starts from a ready-to-instantiate module.

Safari is the wrinkle: it won't let you persist a compiled module, so that step silently falls back to re-downloading and recompiling. The feature degrades, it doesn't break — which is the right behaviour, but it's worth knowing if you're benchmarking.

One maintenance note: when you upgrade the WASM build, bump the database name. Otherwise you'll serve a stale compiled module from the previous version, and you'll spend an afternoon debugging a bug you already fixed.

Wiring files through a virtual filesystem

Pandoc is a CLI program. It expects real paths. So the integration is:

  • Write the uploaded File into the WASI virtual filesystem at an input path
  • Run Pandoc against that path with the right reader and writer flags
  • Read the resulting bytes back out of the virtual filesystem

Most of the fiddly work lives in the option mapping. A couple of examples that cost me time:

  • Pandoc's markdown writer is not GitHub-flavoured Markdown. Turn on GFM input and produce markdown output, and you get a simple table — a Pandoc construct that GitHub renders as literal text, not a table. For CSV and TSV sources you have to target the gfm writer explicitly or the table comes out broken.
  • --extract-media only makes sense for container formats (docx, odt, epub, notebooks). Pass it for a plain-text source and it's at best a no-op, at worst confusing. Gate the option on the source format.
  • "Format" flags are not binary switches. markdown has a pile of extensions (+footnotes, +pipe_tables, -raw_html, …). Getting a sane default set per source format matters more than any single flag.

The honest trade-off

There is one thing the browser build genuinely cannot do: Pandoc's LaTeX → PDF path needs a LaTeX engine, and there isn't a practical one in WASM.

Rather than pretend, Markdown→PDF goes through the browser's own print pipeline with paged CSS (A4, page breaks, margins). You get real, selectable text and a proper print dialog — with none of the layout control a real LaTeX template would give you. If you need publication-grade typesetting, use the CLI. If you need "this Markdown should be a readable PDF right now, offline, without installing anything", this is fine.

Being explicit about that limitation has been more useful than any feature I've shipped. People who need LaTeX know they need LaTeX; everyone else gets a working PDF.

What I'd tell someone starting this

  1. Pick the engine first, and pick the one with the best AST. Client-side pipelines live or die on fidelity, and fidelity is decided before you write any UI.
  2. Never load the engine eagerly. Two-stage loading (shell → engine) is the only way a large WASM dependency works in a public tool.
  3. Cache the compiled module, not the binary. It's the single biggest performance win, and it's a dozen lines of IndexedDB.
  4. Keep the worker boundary clean. Everything runs in the worker; the UI only sends a job and renders a result. It also makes cancellation and batch conversion straightforward.

I put this into wordtomarkdown.com — free, no account, nothing uploaded. If you're building something similar (or fighting the same WASM caching problems), I'd be glad to compare notes in the comments.

Top comments (0)