Engineering · 12 min read

How We Generate a DNA Health Report in the Browser Without a Server (Rust, WebAssembly, Typst)

How a Rust module compiled to WebAssembly parses six raw-DNA formats, matches them against a filtered GWAS Catalog and typesets a 35-page PDF, inside the browser tab.

Floriel FedryCo-founder & Software Engineer

Full Human Health turns a raw DNA export into a PDF health report without a server ever touching the file. One Rust module compiled to WebAssembly runs in the browser tab: it parses the file, matches the genotypes against a filtered GWAS Catalog embedded at compile time, and typesets the PDF with Typst, also compiled to WebAssembly. Here is each stage, with numbers.

The constraint: never receive the file

After 23andMe's Chapter 11 filing on 23 March 2025 (our review of the bankruptcy has the sources), many people downloaded their raw file for the first time, and most of the tools they found asked them to upload it. We wanted the opposite property: a service that cannot leak, sell or be compelled to hand over a genome because it never holds one.

That rules out the usual upload endpoint, queue and worker. Instead, one WebAssembly module takes the file's bytes, a provider name and a language, and returns PDF bytes. The server sees an email address, a credit balance, Stripe records, and per generation two small requests: one reserves a credit with the report type in its body, one completes it. A third, to our own analytics endpoint, names the report type, the PDF language, the provider you picked and the duration in milliseconds. A failed generation sends a refund request instead, without error details, and the credit comes back. The security page lists every request; a short DevTools check shows them to you.

Six provider formats, three parsers

The generator accepts exports from 23andMe, AncestryDNA, MyHeritage, FamilyTreeDNA, Living DNA and WeGene. They reduce to three layouts:

  • 23andMe. Tab-separated, no header line, # comments: rsID, chromosome, position, genotype. Living DNA and WeGene share the shape.
  • MyHeritage. Comma-separated with a header (RSID, CHROMOSOME, POSITION, RESULT). FamilyTreeDNA parses with the same code.
  • AncestryDNA. Tab-separated with allele1 and allele2 columns, joined into one genotype.

Every row passes a keep_rsid closure that checks the catalog's hash map, so a 600,000-row file yields at most 15,122 entries in memory, the number of distinct rsIDs in the health catalog. One caveat about the build that ships today: the parser keeps only homozygous calls (AA, CC, GG, TT) and drops everything else, which removes no-calls as intended but also heterozygous calls such as AG or CT. A fix that accepts any two of A, C, G and T and drops only no-calls is in progress; until it lands, a variant you carry on one chromosome does not appear in the report. The parser also notes X and Y chromosome rows, so that conditions marked for one sex are skipped.

23andMe and AncestryDNA ship a .zip, FamilyTreeDNA a .gz, and the Rust side reads text only, so a small TypeScript module opens archives first. For a zip it reads the end-of-central-directory record, walks the central directory, picks the largest .txt, .csv or .tsv entry, slices the compressed bytes and pipes them through DecompressionStream("deflate-raw"); a .gz goes through DecompressionStream("gzip"). Both formats are in the Compression Streams standard, which MDN lists as widely available since May 2023 (checked 14 October 2026). The decompressed blob stays in memory on the device.

Shrinking the GWAS Catalog, then compiling it in

The NHGRI-EBI GWAS Catalog passed one million curated associations in 2025 (catalog news, checked 14 October 2026), and its eligibility criteria admit a p-value below 1 × 10⁻⁵ from studies that assayed at least 100,000 variants: right for an archive, too loose for a report. A second Rust crate, a command-line preprocessor, reads the ontology-annotated associations file and keeps a row only when all of these hold:

  1. The p-value is below 5 × 10⁻⁸, the usual genome-wide significance line.
  2. The effect column (odds ratio or beta) is not empty, and the interval text is not "NR", a z-score or a unit-denominated beta.
  3. The 95% confidence interval parses, and its upper bound is less than three times its lower bound.
  4. At least one mapped trait is a disease: a MONDO term, or an EFO term that EFO's cross-reference file (SSSOM format) maps to one.

For the release we use, 2026-06-01, that leaves 23,220 associations covering 1,114 conditions and traits from 1,459 studies. A second filter builds the nutrition catalog from a hand-written list of vitamin, mineral, trace-element and food-sensitivity measurements: 10,443 associations, 292 traits, 157 studies. Our GWAS Catalog article explains the rows.

Both catalogs are compiled into the module: include_str! pulls in the two TSVs (5.1 MB), the EFO mapping (3.7 MB) and the condition texts in three languages (3.7 MB), include_bytes! the six font files (2.2 MB). On first use the TSVs are parsed once into hash maps keyed by rsID, behind a OnceLock. The module has no network code; its exports take bytes and strings and return bytes.

Matching and scoring

The method is written out on the methodology page; this is what the code does. For every rsID in the file that exists in the catalog, the generator keeps each association whose risk allele (the allele after the hyphen in a value like rs1447295-A) occurs in the genotype string, which with today's homozygous-only parser means a genotype like AA for a risk allele A; then drops duplicates that share a PubMed ID, allele and trait. Associations are grouped by trait URI, one table per condition, with rows keyed by rsID, genotype and risk allele.

Several studies often report the same variant, so the effect per row is the average within each study, then across studies, printed next to the PubMed IDs it came from; a beta takes its sign from the interval text. The verdict per condition is the product of the row effects for odds ratios, or the sum for betas: above 1 (or 0) prints as higher risk, below as lower, exactly 1 or no matched row as average or undetermined.

This is deliberately simple, and it is not a polygenic risk score. It does not weight by zygosity, does not correct for linkage disequilibrium, uses no baseline population risk, and weights every study the same regardless of sample size or ancestry. Most GWAS participants are of European ancestry, a consumer array holds only a fraction of the catalog's lead variants, often around a quarter, and effects below about 1.3 are weak. One more limit: a condition gets pages only when we have written a text for it, 263 per language today, so a matched trait without a text does not appear.

A 35-page PDF from Typst, compiled to WebAssembly

The report is assembled as Typst markup in memory: a setup block declaring fonts, table styling and the cover plates, then the cover, an outline, a summary table per category, and a section per category with each condition's text and evidence table. The cover plates are drawn in Typst code; the document embeds no images.

The typst crate compiles that markup inside the module. Typst asks its host for everything through a World trait, and ours is short: one source at a virtual path, the standard library, a FontBook built from the six embedded faces, and a file() method that always answers "not found". typst_pdf::pdf writes the bytes, which the page wraps in a Blob and downloads through a local object URL. One detail cost us an afternoon: Typst memoizes layout work across compilations through comemo, every report is a new document, and the cache grew until the module ran out of memory. comemo::evict(0) after each compile fixed it.

Our CI check generates every report type in every language from a synthetic genome of 3,000 catalog rsIDs, in Node. On an Apple M1 Max with Node 22, the complete health report takes three to six seconds, a nutrition report about 0.2 seconds, and all 57 PDFs 24 seconds, with module memory at 474 MB afterwards. The check fails on any failed generation, on memory above 1 GB, or on a health report with fewer than 100 variant rows. The sample report, generated from a real 23andMe file, is 35 pages.

39.5 MB: the size and the trade-offs

The module is 39,550,505 bytes uncompressed. About 14.7 MB is the data above; the rest is code, dominated by Typst's compiler, layout engine and PDF writer. The release profile uses opt-level = "s" and link-time optimisation, wasm-opt runs at -O1 because higher levels cost more Vercel build time than they saved in bytes, and the stack is raised to 5 MB for Typst's recursion.

The browser does not pay the full price. The module is a content-hashed static asset served with Cache-Control: public,max-age=31536000,immutable (the header our /_next/static/ assets return in production, checked 14 October 2026), so it downloads once per deployment. Vercel's CDN compresses wasm responses with Brotli or gzip when the browser asks (both checked 14 October 2026); on our local build, gzip at level 9 gives 12.1 MB and Brotli at quality 11 gives 8.2 MB. The CDN's settings may differ, so the Size column in your Network tab is the number to trust.

The costs are real. A first visit on a slow connection waits; generation runs on the main thread, so the tab is busy for the seconds the compile takes; the page must stay open; a phone with little memory may struggle with the complete report. We accepted that because the alternative is the catalog on a server, next to your file.

A GitHub Action refreshes the catalog every Monday

The catalog is released roughly every two weeks (checked 14 October 2026), so a workflow runs every Monday at 06:17 UTC:

  1. Read data_release_date from the catalog's metadata API and compare it with the manifest in the repository. Same date, nothing to do.
  2. Resolve the download folder from the FTP listing: the first day folder on or after that date, because folders are named after the upload day and can trail the release (2026-10-03 was published under 2026/10/05).
  3. Download gwas-catalog-associations_ontology-annotated-full.zip, check its columns, and run the preprocessor into a temporary directory.
  4. Stop if either catalog shrank below 70% of its current rows: a sudden drop means a vocabulary or format change, not fewer studies.
  5. Write the manifest (release date, dbSNP build, genome build, EFO version) and open a pull request on a fixed branch.

A person reviews the diff, CI runs the generation check, and merging rebuilds the module on the next deployment. The lag is visible: we are on release 2026-06-01 with EFO v3.90.0, while the metadata API reported 2026-10-03 and EFO v3.94.0 on 14 October 2026. The methodology page shows the release in use.

What we deliberately do not do

  • No server-side generation, not even as a fallback. If the module fails to load, the button says so and nothing runs elsewhere.
  • Nothing fetched during generation. The module is the whole program. The two credit requests carry the report type and nothing else, and the analytics event carries the report type, language, provider and duration: no file name, no genotype.
  • No imputation. A consumer array reads a few hundred thousand positions, and those are what we read; absent variants print as undetermined. No VCF or whole-genome input.
  • No interpretation outside the catalog. No pharmacogenomics, carrier status, rare pathogenic variants or ancestry, and no candidate-gene variants that never reached genome-wide significance: MTHFR rs1801133 and COMT rs4680 are not in the catalog, so not in the report.

Open questions

Open-sourcing. The generator's source is not public yet. Our other browser tool, Orphos, is GPL-3.0 on GitHub and shows how we would do it; the preprocessing CLI is the obvious first candidate. We have not decided, and would rather say so.

A reproducible build hash. The toolchain (Rust 1.97.1), the lock file and wasm-pack 0.15.0 are pinned, and Vercel reuses the module whenever the hash of its inputs is unchanged. That is a cache key, not a proof. We have not verified bit-for-bit reproducibility across machines and do not publish the SHA-256 of the served module. Until then, the Network tab is the evidence.

A Content Security Policy. The site sends HSTS and no Content-Security-Policy header today (curl -I, checked 14 October 2026). A connect-src limited to our domain and Stripe would make the browser itself refuse to send data anywhere else; it needs testing against Stripe Checkout and sign-in first.

A worker and a newer Typst. A Web Worker would keep the tab responsive during the compile. We are pinned to Typst 0.14.2 from December 2025; 0.15.1 is current since July 2026 (checked 14 October 2026), and the upgrade is on the list.

FAQ

Why not generate on a server and delete the file afterwards?

Because "we delete it" is a promise and "we never receive it" is a property you can check. A server that holds the file for a minute can be breached or misconfigured in that minute. A module in your tab has nowhere to send the file, and the Network tab shows it.

Does the 40 MB download happen for every report?

No. The module is a content-hashed static asset with a one-year immutable cache header, so your browser downloads it once per deployment and reuses it for every report after that. In transit it is compressed: 12.1 MB with gzip and 8.2 MB with Brotli on our local build.

Why Typst instead of a PDF library?

A health report is a typeset document: a cover, an outline, tables that break across pages, hyphenated German titles and thousands of links. Typst produces all of that from generated markup, compiles to WebAssembly with the fonts embedded, and needs no file system. A PDF writer would have meant writing the layout engine ourselves.

How do I check what the module sends?

Open the developer tools, switch to the Network tab, select your file and generate a report. You will see the module download once, a request that reserves a credit with the report type in its body, one that completes it, and one analytics event naming the report type, language, provider and duration. Nothing the size of your file leaves the page.

From open-source engineering to personal genomics

Explore Full Human's privacy-first DNA health reports

Orphos and Full Human Health solve different problems. The shared principle is privacy by design: compatible raw DNA files are processed locally in your browser, with report findings linked to scientific sources.

Full Human reports are informational and are not a medical diagnosis.

TopicsRustWebAssemblyTypstGWAS Catalogengineeringbrowser-based DNA analysisprivacy by design