<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: yixiang xiao</title>
    <description>The latest articles on DEV Community by yixiang xiao (@yixiang_xiao_7e85b6ed0d35).</description>
    <link>https://dev.to/yixiang_xiao_7e85b6ed0d35</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4193117%2Fda40ce37-10e6-4875-920a-781058ccdc77.png</url>
      <title>DEV Community: yixiang xiao</title>
      <link>https://dev.to/yixiang_xiao_7e85b6ed0d35</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yixiang_xiao_7e85b6ed0d35"/>
    <language>en</language>
    <item>
      <title>Running Pandoc in the Browser: WebAssembly, Web Workers, and a 58 MB Module</title>
      <dc:creator>yixiang xiao</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:04:41 +0000</pubDate>
      <link>https://dev.to/yixiang_xiao_7e85b6ed0d35/running-pandoc-in-the-browser-webassembly-web-workers-and-a-58-mb-module-4hia</link>
      <guid>https://dev.to/yixiang_xiao_7e85b6ed0d35/running-pandoc-in-the-browser-webassembly-web-workers-and-a-58-mb-module-4hia</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Pandoc, and not an HTML pipeline
&lt;/h2&gt;

&lt;p&gt;The obvious approach for &lt;code&gt;.docx&lt;/code&gt; → 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.&lt;/p&gt;

&lt;p&gt;It falls apart on the things that make Word documents &lt;em&gt;Word documents&lt;/em&gt;: 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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Footnotes stay footnotes, with their original numbering&lt;/li&gt;
&lt;li&gt;Citations survive as citation AST nodes (and can be processed by a CSL processor)&lt;/li&gt;
&lt;li&gt;Math survives as math, not as a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; with a font-family&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One engine covers the whole matrix.&lt;/strong&gt; 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&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compiling Pandoc to WebAssembly
&lt;/h2&gt;

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

&lt;p&gt;58 MB is a non-starter if you load it on page load. Nobody is going to sit through that to &lt;em&gt;look&lt;/em&gt; at a tool. So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load it lazily, on intent.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it in a Web Worker.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone underestimates: compilation, not download
&lt;/h2&gt;

&lt;p&gt;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. &lt;strong&gt;Compiling it is the slow part&lt;/strong&gt; — &lt;code&gt;WebAssembly.compile()&lt;/code&gt; on a module that size takes real time, every time, on every page load.&lt;/p&gt;

&lt;p&gt;The fix is to cache the &lt;em&gt;compiled&lt;/em&gt; artifact, not the bytes. &lt;code&gt;WebAssembly.Module&lt;/code&gt; is structured-cloneable, so you can hand it to IndexedDB and get it back later without recompiling:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;IndexedDB&lt;/strong&gt; — look up the previously compiled &lt;code&gt;WebAssembly.Module&lt;/code&gt; and instantiate it directly. No download, no compile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache API&lt;/strong&gt; — if IDB has nothing, read the raw bytes from the HTTP cache and compile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming download&lt;/strong&gt; — last resort, fetch and stream-compile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Wiring files through a virtual filesystem
&lt;/h2&gt;

&lt;p&gt;Pandoc is a CLI program. It expects real paths. So the integration is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write the uploaded &lt;code&gt;File&lt;/code&gt; into the WASI virtual filesystem at an input path&lt;/li&gt;
&lt;li&gt;Run Pandoc against that path with the right reader and writer flags&lt;/li&gt;
&lt;li&gt;Read the resulting bytes back out of the virtual filesystem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the fiddly work lives in the option mapping. A couple of examples that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pandoc's&lt;/strong&gt; &lt;strong&gt;&lt;code&gt;markdown&lt;/code&gt;&lt;/strong&gt; &lt;strong&gt;writer is not GitHub-flavoured Markdown.&lt;/strong&gt; Turn on GFM input and produce &lt;code&gt;markdown&lt;/code&gt; output, and you get a &lt;em&gt;simple table&lt;/em&gt; — a Pandoc construct that GitHub renders as literal text, not a table. For CSV and TSV sources you have to target the &lt;code&gt;gfm&lt;/code&gt; writer explicitly or the table comes out broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--extract-media&lt;/code&gt;&lt;/strong&gt; &lt;strong&gt;only makes sense for container formats&lt;/strong&gt; (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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Format" flags are not binary switches.&lt;/strong&gt; &lt;code&gt;markdown&lt;/code&gt; has a pile of extensions (&lt;code&gt;+footnotes&lt;/code&gt;, &lt;code&gt;+pipe_tables&lt;/code&gt;, &lt;code&gt;-raw_html&lt;/code&gt;, …). Getting a sane default set per source format matters more than any single flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

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

&lt;p&gt;I put this into &lt;a href="https://wordtomarkdown.com" rel="noopener noreferrer"&gt;wordtomarkdown.com&lt;/a&gt; — 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.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
