PCA with webR

In a previous post I rebuilt a Factoshiny style PCA tool by porting the math to JavaScript. It works, but it is a reimplementation: every future feature of FactoMineR would have to be ported by hand.
So here is the other approach. This page runs the actual R package, in your browser, through webR. The charts are still React and d3.js, but every number on them comes from a real call to FactoMineR::PCA().
🎮 The tool
Press the button and wait. Your browser is about to download an entire R interpreter, plus FactoMineR and the packages it depends on. That is a few tens of megabytes, so it takes something like 30 seconds the first time. Everything is cached afterwards.
Once R is up, the controls behave like any other web app: each toggle sends a fresh PCA() call to R and redraws. The status bar shows how long R took.
This page runs the real FactoMineR package, so it first has to download an R interpreter compiled to WebAssembly and the package library that goes with it. Expect something like 30 seconds and a few tens of megabytes on the first visit. Your browser caches all of it afterwards.
🤓 How it works
webR is R itself, compiled to WebAssembly. It runs in a web worker, so the interpreter never blocks the page, and it comes with a package repository of CRAN builds compiled for the same target. FactoMineR is in there, which is the only reason this page is possible.
→ Loading R
The one thing to be careful about in a Next.js app is that webR must never be bundled. It is loaded from its CDN with a dynamic import that the bundler is told to leave alone:
// Load webR from its CDN, at runtime, so the bundler never sees it
const { WebR } = await import(
/* webpackIgnore: true */ /* turbopackIgnore: true */
'https://webr.r-wasm.org/v0.6.0/webr.mjs'
);
const webR = new WebR({ baseUrl: 'https://webr.r-wasm.org/v0.6.0/' });
await webR.init();
await webR.installPackages(['FactoMineR']);
// Hand the data over through webR's virtual filesystem
await webR.FS.writeFile('/tmp/data.csv', new TextEncoder().encode(csv));
// Run R, and ship the result back as JSON
const shelter = await new webR.Shelter();
const output = await shelter.evalR(`
res <- FactoMineR::PCA(df, graph = FALSE)
jsonlite::toJSON(list(eig = unname(res$eig[, 1])), digits = 10)
`);
const result = JSON.parse(await output.toString());
shelter.purge();Data goes in through webR's virtual filesystem as a csv, and results come back as a JSON string built by jsonlite. Passing plain text in both directions avoids any guesswork about how an R matrix maps onto a JavaScript object.
→ The R that runs
There is no trick here. The script below is what gets evaluated, and you can see the live version of it at any moment with the Show the R code button above the charts.
# This is what actually runs, in a WebAssembly R session
df <- read.csv("/tmp/data.csv", row.names = 1, check.names = FALSE)
X <- df[, c("Murder", "Assault", "UrbanPop", "Rape"), drop = FALSE]
res <- FactoMineR::PCA(
X,
scale.unit = TRUE,
ncp = 4,
quali.sup = NULL,
graph = FALSE
)→ No special headers needed
webR is faster when the page is cross origin isolated, which needs COOP and COEP headers. This gallery embeds a lot of third party iframes, and those headers would break them, so the page does not set them. webR notices and falls back to a postMessage channel on its own. The only things lost are interrupting a running computation and reading from stdin, neither of which a PCA needs.
⚖️ Is it worth it?
Both versions of this tool draw the same charts from the same datasets, so the comparison is unusually clean.
→ What webR wins
- It is the real thing. No reimplementation to keep in sync, no doubt about whether an edge case matches R.
- Everything the package offers is one line away. The category barycentres and the
v.testtable on this page come fromquali.sup, a single argument. Adding them to the JavaScript version meant writing them. - The rest of CRAN is available too. Once R is loaded, adding clustering or a mixed data analysis is an
install.packages()away.
→ What it costs
- The first load. Tens of megabytes and about half a minute, against a few kilobytes for the JavaScript version. That is the whole story, and it is a big one for a public page.
- No server rendering. The charts cannot exist until R is up, so there is nothing to show a crawler and nothing to show a visitor who leaves after 5 seconds.
- A heavier moving part. A WebAssembly R session in a worker is more to go wrong than a function that multiplies matrices.
Once R is warm though, the cost disappears: FactoMineR::PCA() comes back in a handful of milliseconds on these datasets, which is fast enough to recompute on every click.
So the answer is the boring one. For a small, well understood computation that you want on a public page, port it: the JavaScript version loads instantly and weighs nothing. For an internal tool, a teaching app, or anything that leans on what R packages actually contain, webR saves you from rewriting a library that already exists.
Conclusion
The 2 versions agree to 8 decimal places on every dataset here, which was a good way to check the hand written one. That is probably the most practical use of webR for a JavaScript developer: not necessarily shipping it, but keeping a real R session around to test your port against.
Contact
👋 Hey, I'm Yan and I'm currently working on this project!
Feedback is welcome ❤️. You can fill an issue on Github, drop me a message on LinkedIn, or even send me an email pasting yan.holtz.data with gmail.com. You can also subscribe to the newsletter to know when I publish more content!




