Running R and Python in a page

Put code that students can edit and run into a teaching page, with nothing to install and no server to run it. webR and Pyodide suit a few code boxes on a page, JupyterLite suits practicals taught as notebooks, and Quarto Live suits exercises with feedback.

Which option to use

Feature webR Pyodide JupyterLite Quarto Live PyScript
What it is R compiled to WebAssembly, driven by your page’s JavaScript Python compiled to WebAssembly, driven the same way JupyterLab and Jupyter Notebook as a static website, with Python or R running in the browser A Quarto extension that turns code cells into boxes run by webR or Pyodide Python written into a page’s HTML, run by Pyodide
First load, measured 14 MB, 4–6 s; with ggplot2, 51 MB and 16–23 s 6 MB, 3–6 s; with numpy, matplotlib and Biopython, 22 MB A console: 14 MB, 16–21 s. A notebook with Biopython and a plot: 33 MB, 18–20 s A page with R and Python: 50 MB, 20 s 6 MB, 4–12 s
Offline after a visit No Only while the page is in the browser’s cache Not tested No Not tested
Packages 24,638 CRAN and 2,402 Bioconductor packages, but not DESeq2 or edgeR About 350 built for Pyodide, and pure-Python packages from PyPI Pyodide’s, or a fixed set chosen when you build the site webR’s, and those of an older Pyodide Pyodide’s
Keeps students’ work No No Yes, in the browser, and as downloaded notebooks Not by default No
Build step None None A GitHub Actions workflow quarto render when you write the page None
Licence MIT, and GPL for R itself MPL 2.0 BSD 3-Clause MIT Apache 2.0

webR and Pyodide suit a few code boxes on an otherwise ordinary page, as in the first two examples below. JupyterLite suits practicals taught as notebooks, and Quarto Live suits course pages written in Quarto, with exercises that check answers. PyScript lets you write Python straight into a page’s HTML. It suits small demonstrations, but it adds a layer, with its own releases, on top of Pyodide. None of them needs a server that runs code, accounts or keys, or special headers from your web host, so they work on a plain static host such as GitHub Pages.

We tested everything on 15 October 2026 in Chromium, with webR 0.6.0 (R 4.6.0), Pyodide 314.0.7 (Python 3.14.2), JupyterLite 0.8.5, Quarto 1.10.18 with Quarto Live 0.2.0, and PyScript 2026.7.3. Times were measured on a fast connection: on a 20 Mbit/s connection, add about 4 seconds for every 10 MB. We did not test Firefox, Safari, or real tablets and phones.

Put R code boxes in a page with webR

The core of webR is a few lines. It starts R in a Web Worker, a background thread, so the page keeps responding while R works:

import { WebR } from 'https://webr.r-wasm.org/v0.6.0/webr.mjs';

const webR = new WebR();                   // R runs in a Web Worker
await webR.init();                         // downloads about 14 MB the first time
const shelter = await new webR.Shelter();  // keeps track of the R objects made below
const result = await shelter.captureR('gc <- c(0.41, 0.70, 0.39)\nmean(gc)', {
  withAutoprint: true,                     // print values, as R's console does
  captureStreams: true
});
console.log(result.output.map((line) => line.data).join('\n'));   // [1] 0.5
shelter.purge();                           // free those R objects

The example builds code boxes on that. Each box is a labelled text area with Run and Reset buttons, and all the boxes on a page share one R session. Students simulate a DNA sequence with a GC-rich stretch, plot its GC content in sliding windows and find where the stretch lies. Because the data are simulated, students can check their answer against the truth.

This example runs R in your browser. It downloads about 14 MB when you first click in a box or press Run.

Code webr-code-boxes.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>webR: R code boxes, with GC content in sliding windows</title>
  <style>
    body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
    h1 { font-size: 1.05rem; margin: 0 0 6px; }
    p { margin: 0 0 10px; }
    .r-box { border: 1px solid #c3cbd8; border-radius: 6px; padding: 8px 10px 10px; margin: 0 0 12px; }
    .r-box label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 4px; }
    .r-box textarea { width: 100%; box-sizing: border-box; font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #172033;
                      padding: 6px 8px; border: 1px solid #8a94a6; border-radius: 4px; resize: vertical;
                      white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 2; }
    .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
    button { font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 4px; border: 1px solid #5c677a; background: #f4f6fa; color: #172033; cursor: pointer; }
    button.run { background: #2b56c9; border-color: #2b56c9; color: #fff; }
    button:disabled { opacity: 0.55; cursor: progress; }
    button:focus-visible, textarea:focus-visible, pre:focus-visible { outline: 3px solid #b35a00; outline-offset: 2px; }
    .state { font-size: 13px; color: #3b4659; }
    pre.out { font: 13px/1.4 ui-monospace, Menlo, Consolas, monospace; background: #f4f6fa; border: 1px solid #dde2ea; border-radius: 4px;
              padding: 6px 8px; margin: 0; white-space: pre; overflow-x: auto; }
    pre.out:empty { display: none; }
    pre.out .err { color: #a40000; }
    .plots img { display: block; max-width: 100%; height: auto; margin-top: 8px; }
  </style>
</head>
<body>
  <h1>GC content in sliding windows, in R</h1>
  <p>Edit the code and press <b>Run</b>, or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>. R runs in your browser. It downloads (about 14 MB)
     when you first click in a box, so the first Run takes a few seconds. The boxes share one R session, so run them in order.</p>

  <div class="r-box">
    <label for="code1">1. Make a DNA sequence with a GC-rich stretch in the middle (simulated data)</label>
    <textarea id="code1" spellcheck="false">set.seed(1)                           # the same "random" sequence every time
bases <- c("A", "C", "G", "T")
ordinary <- c(0.3, 0.2, 0.2, 0.3)     # chances of A, C, G and T: 40% GC
gc_rich  <- c(0.15, 0.35, 0.35, 0.15) # 70% GC
dna <- c(sample(bases, 1500, replace = TRUE, prob = ordinary),
         sample(bases, 1000, replace = TRUE, prob = gc_rich),
         sample(bases, 1500, replace = TRUE, prob = ordinary))
length(dna)
table(dna)
mean(dna %in% c("G", "C"))            # GC content of the whole sequence</textarea>
  </div>

  <div class="r-box" data-plot-alt="Line plot of GC fraction along the simulated sequence, made by the code above. The summary printed above the plot gives the same values as text.">
    <label for="code2">2. GC content in sliding windows. Before you run it again, predict what a window of 50 or 1000 will do.</label>
    <textarea id="code2" spellcheck="false">window <- 200    # bases in each window
step <- 50       # how far the window moves each time
starts <- seq(1, length(dna) - window + 1, by = step)
gc <- sapply(starts, function(s) mean(dna[s:(s + window - 1)] %in% c("G", "C")))
summary(gc)
plot(starts + window / 2, gc, type = "l", lwd = 2, ylim = c(0, 1),
     xlab = "Position (bases)", ylab = "GC fraction",
     main = paste("GC content in", window, "base windows"))
abline(h = mean(dna %in% c("G", "C")), lty = 2)   # the whole sequence</textarea>
  </div>

  <div class="r-box">
    <label for="code3">3. Where do the GC-rich windows start and end? The true stretch is bases 1,501 to 2,500.</label>
    <textarea id="code3" spellcheck="false">rich <- starts[gc > 0.55]
c(first = min(rich), last = max(rich) + window - 1)</textarea>
  </div>

  <script type="module">
    /* Runnable R code boxes, with webR 0.6.0 (R 4.6.0).
       Every .r-box that holds a <textarea> gets Run and Reset buttons and an output area. All the boxes share one
       R session, which starts the first time someone runs or edits a box.
       Options: <body data-r-packages="ape seqinr"> installs packages as R starts;
                <div class="r-box" data-time-limit="60"> changes the time limit (in seconds, default 30);
                <div class="r-box" data-plot-alt="…"> describes the box's plot for screen readers. */
    const WEBR = 'https://webr.r-wasm.org/v0.6.0/webr.mjs';   // pinned. webR fetches more files of its own, so there is no integrity hash.

    // The R that runs a box. It prints values as R's console does, and stops the code after a time limit: without
    // cross-origin isolation a runaway loop cannot be interrupted any other way. The limit is removed however the code ends.
    const RUN = `(function() {
      setTimeLimit(elapsed = limit, transient = TRUE)
      on.exit(setTimeLimit(elapsed = Inf), add = TRUE)
      withAutoprint(parse(text = code), evaluated = TRUE, local = globalenv(), echo = FALSE)
      invisible()
    })()`;

    let webRPromise = null;
    let queue = Promise.resolve();     // boxes run one at a time, in the order they were asked

    function startR() {
      if (!webRPromise) {
        webRPromise = (async () => {
          // Imported here rather than at the top, so that the page still works, and can say what is wrong, if it fails
          const { WebR } = await import(WEBR).catch(() => {
            // a browser does not fetch a module again after a failed import: only reloading the page retries
            throw new Error('R could not be downloaded from webr.r-wasm.org. Check the internet connection, then reload the page.');
          });
          const webR = new WebR();     // without cross-origin isolation, webR chooses its PostMessage channel itself
          await webR.init();
          // install.packages() and library() of a missing package then fetch packages built for webR (repo.r-wasm.org)
          await webR.evalRVoid('webr::shim_install(); options(webr.show_menu = FALSE)');
          // packages the page needs, installed here so that they do not count against a box's time limit
          const pkgs = (document.body.dataset.rPackages || '').split(/\s+/).filter(Boolean);
          if (pkgs.length) await webR.installPackages(pkgs, { quiet: true });
          return webR;
        })();
        webRPromise.catch(() => { webRPromise = null; });   // R did not start: try again at the next Run
      }
      return webRPromise;
    }

    // webR returns each plot as an ImageBitmap drawn at twice the size, for sharp lines. An <img> can be saved or copied.
    function bitmapToImg(bitmap, alt) {
      const canvas = document.createElement('canvas');
      canvas.width = bitmap.width;
      canvas.height = bitmap.height;
      canvas.getContext('2d').drawImage(bitmap, 0, 0);
      const img = document.createElement('img');
      img.src = canvas.toDataURL('image/png');
      img.width = bitmap.width / 2;
      img.alt = alt;
      return img;
    }

    for (const box of document.querySelectorAll('.r-box')) {
      const ta = box.querySelector('textarea');
      const original = ta.value;
      ta.rows = Math.max(3, original.split('\n').length + 1);    // show all of the code
      const bar = document.createElement('div');
      bar.className = 'bar';
      bar.innerHTML = '<button type="button" class="run">Run</button><button type="button" class="reset">Reset</button>' +
                      '<span class="state" role="status" aria-live="polite"></span>';
      const out = document.createElement('pre');
      out.className = 'out';
      out.tabIndex = 0;                // long output scrolls sideways, so it must be reachable by keyboard
      out.setAttribute('aria-label', 'Output');
      const plots = document.createElement('div');
      plots.className = 'plots';
      box.append(bar, out, plots);
      const runButton = bar.querySelector('.run');
      const state = bar.querySelector('.state');

      function run() {
        if (runButton.disabled) return;
        runButton.disabled = true;
        out.textContent = '';
        plots.replaceChildren();
        state.textContent = webRPromise ? 'Waiting…' : 'Starting R (first time only)…';
        queue = queue.then(async () => {
          let shelter = null;
          try {
            const webR = await startR();
            state.textContent = 'Running…';
            shelter = await new webR.Shelter();       // frees the R objects made for this run in one go
            const t0 = performance.now();
            const result = await shelter.captureR(RUN, {
              env: { code: ta.value, limit: Number(box.dataset.timeLimit) || 30 },
              captureStreams: true,
              captureConditions: false,               // errors, warnings and messages arrive as text, in order
              captureGraphics: { width: 640, height: 360 }
            });
            for (const line of result.output) {
              const span = document.createElement('span');
              if (line.type === 'stderr') span.className = 'err';
              span.textContent = line.data + '\n';
              out.append(span);
            }
            const alt = box.dataset.plotAlt || 'Plot made by the R code above';
            result.images.forEach((bitmap) => plots.append(bitmapToImg(bitmap, alt)));
            // Errors, including the time limit, arrive as text on stderr, starting "Error"
            const ok = !result.output.some(line => line.type === 'stderr' && /^Error/.test(line.data));
            state.textContent = (ok ? 'Done' : 'Stopped with an error') + ' (' + ((performance.now() - t0) / 1000).toFixed(1) + ' s).';
          } catch (e) {
            out.textContent += 'Error: ' + e.message;
            state.textContent = 'Failed.';
          } finally {
            if (shelter) await shelter.purge();
            runButton.disabled = false;
          }
        });
      }

      runButton.addEventListener('click', run);
      bar.querySelector('.reset').addEventListener('click', () => { ta.value = original; ta.focus(); });
      ta.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); run(); }
      });
      ta.addEventListener('focus', startR, { once: true });   // start downloading R as soon as someone starts to edit
    }
  </script>
</body>
</html>

How it works:

  • Starting R. The page imports webR only when a box is first used, and if the download fails it says so, rather than leaving buttons that do nothing. R starts as soon as someone clicks in a box, so it is usually ready when they press Run. The first Run took 4 to 6 seconds, and later runs about a second or less.
  • Running a box. captureR() returns what the code printed, in order, and its plots. With captureConditions: false, errors, warnings and messages arrive as text, and the page shows them in red.
  • Plots arrive as images drawn at twice their size, so lines stay sharp. A box’s data-plot-alt describes its plot for screen readers, and the code prints the same values as text with summary().
  • Runaway code. The page cannot interrupt R (see stopping runaway code), so every run has a time limit, set with R’s setTimeLimit(): 30 seconds, or what the box’s data-time-limit says. An endless loop stops with “reached elapsed time limit”, and R carries on with its variables intact.
  • Packages. webr::shim_install() lets install.packages() and library() fetch packages built for webR. List the packages every box needs in <body data-r-packages="ape seqinr">, so that they install while R starts rather than during a timed run.

The educators’ page turns these boxes into an activity.

Run Python in the background with Pyodide

Pyodide’s core is just as short. These lines run Python on the page’s own thread, which freezes the page while Python works, so keep them for a line or two of code:

import { loadPyodide } from 'https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.mjs';

const pyodide = await loadPyodide();       // downloads about 6 MB the first time
pyodide.setStdout({ batched: (line) => console.log(line) });
await pyodide.runPythonAsync('seq = "ATGGTGCAT"\nprint(seq[::-1].translate(str.maketrans("ACGT", "TGCA")))');   // ATGCACCAT

The example runs Python in a Web Worker instead, behind the same kind of code boxes. It uses the coding sequence of the human β-globin gene, HBB, from RefSeq (NM_000518.5) [1]: its GC content, its reverse complement, its translation with the genetic code, and the sickle-cell change c.20A>T, which turns codon 7 from GAG (glutamate) into GTG (valine). The last box checks the translation with Biopython [2] and draws a chart with matplotlib.

This example runs Python in your browser. It downloads about 6 MB when you first click in a box or press Run, and about 15 MB more for Biopython and matplotlib when box 4 first runs.

Code pyodide-code-boxes.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Pyodide: Python code boxes, with the HBB coding sequence</title>
  <style>
    body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
    h1 { font-size: 1.05rem; margin: 0 0 6px; }
    p { margin: 0 0 10px; }
    .py-box { border: 1px solid #c3cbd8; border-radius: 6px; padding: 8px 10px 10px; margin: 0 0 12px; }
    .py-box label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 4px; }
    .py-box textarea { width: 100%; box-sizing: border-box; font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #172033;
                       padding: 6px 8px; border: 1px solid #8a94a6; border-radius: 4px; resize: vertical;
                       white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 4; }
    .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
    button { font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 4px; border: 1px solid #5c677a; background: #f4f6fa; color: #172033; cursor: pointer; }
    button.run { background: #2b56c9; border-color: #2b56c9; color: #fff; }
    button:disabled { opacity: 0.55; cursor: default; }
    button:focus-visible, textarea:focus-visible, pre:focus-visible { outline: 3px solid #b35a00; outline-offset: 2px; }
    .state { font-size: 13px; color: #3b4659; }
    pre.out { font: 13px/1.4 ui-monospace, Menlo, Consolas, monospace; background: #f4f6fa; border: 1px solid #dde2ea; border-radius: 4px;
              padding: 6px 8px; margin: 0; white-space: pre; overflow-x: auto; }
    pre.out:empty { display: none; }
    pre.out .err { color: #a40000; }
    .figs img { display: block; max-width: 100%; height: auto; margin-top: 8px; background: #fff; }
  </style>
</head>
<body>
  <h1>The β-globin gene, HBB, in Python</h1>
  <p>Edit the code and press <b>Run</b>, or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>. Python runs in your browser, in the background.
     It downloads (about 6 MB) when you first click in a box, and box 4 fetches Biopython and matplotlib. The boxes share
     one Python session, so run them in order.</p>

  <div class="py-box">
    <label for="py1">1. The HBB coding sequence: length and GC content</label>
    <textarea id="py1" spellcheck="false"># Human HBB coding sequence, RefSeq NM_000518.5 bases 51-494: start codon to stop codon
hbb = (
    "ATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGGTGAACGTGGATGAAG"
    "TTGGTGGTGAGGCCCTGGGCAGGCTGCTGGTGGTCTACCCTTGGACCCAGAGGTTCTTTGAGTCCTTTGG"
    "GGATCTGTCCACTCCTGATGCTGTTATGGGCAACCCTAAGGTGAAGGCTCATGGCAAGAAAGTGCTCGGT"
    "GCCTTTAGTGATGGCCTGGCTCACCTGGACAACCTCAAGGGCACCTTTGCCACACTGAGTGAGCTGCACT"
    "GTGACAAGCTGCACGTGGATCCTGAGAACTTCAGGCTCCTGGGCAACGTGCTGGTCTGTGTGCTGGCCCA"
    "TCACTTTGGCAAAGAATTCACCCCACCAGTGCAGGCTGCCTATCAGAAAGTGGTGGCTGGTGTGGCTAAT"
    "GCCCTGGCCCACAAGTATCACTAA"
)
gc = (hbb.count("G") + hbb.count("C")) / len(hbb)
print(len(hbb), "bases,", len(hbb) // 3, "codons")
print(f"GC content: {gc:.1%}")</textarea>
  </div>

  <div class="py-box">
    <label for="py2">2. Reverse complement and translation, by hand</label>
    <textarea id="py2" spellcheck="false"># The other strand, read 5' to 3': complement every base, then reverse
pairs = str.maketrans("ACGT", "TGCA")
other_strand = hbb.translate(pairs)[::-1]
print("Reverse complement:", other_strand[:30], "...")

# The standard genetic code (NCBI table 1), codons listed in TCAG order
codons = [a + b + c for a in "TCAG" for b in "TCAG" for c in "TCAG"]
genetic_code = dict(zip(codons, "FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG"))

def to_protein(dna):
    return "".join(genetic_code[dna[i:i + 3]] for i in range(0, len(dna) - 2, 3))

protein = to_protein(hbb)
print("Protein:", protein)          # * marks the stop codon</textarea>
  </div>

  <div class="py-box">
    <label for="py3">3. The sickle-cell variant, c.20A&gt;T. Then try c.19G&gt;A (HbC) or c.79G&gt;A (HbE).</label>
    <textarea id="py3" spellcheck="false">position, new_base = 20, "T"        # HGVS counts from 1; Python counts from 0
variant = hbb[:position - 1] + new_base + hbb[position:]
codon = (position - 1) // 3          # the codon that holds the base, counted from 0
before = hbb[3 * codon:3 * codon + 3]
after = variant[3 * codon:3 * codon + 3]
print(f"Codon {codon + 1}: {before} -> {after}")
print(f"Amino acid: {protein[codon]} -> {to_protein(variant)[codon]}")</textarea>
  </div>

  <div class="py-box" data-plot-alt="Bar chart of the GC content at the first, second and third positions of the codons of HBB. The values printed above the chart give the same information as text.">
    <label for="py4">4. Packages: Biopython checks your translation, and matplotlib draws a chart</label>
    <textarea id="py4" spellcheck="false">from Bio.Seq import Seq
from Bio.SeqUtils import gc_fraction
import matplotlib.pyplot as plt

print("Biopython agrees:", Seq(hbb).translate() == protein)

# GC content at the first, second and third base of every codon
by_position = [gc_fraction(hbb[i::3]) for i in range(3)]
print("GC at codon positions 1, 2, 3:", [round(x, 2) for x in by_position])

fig, ax = plt.subplots(figsize=(5, 3))
ax.bar(["1st", "2nd", "3rd"], by_position, color="#2b56c9")
ax.set(xlabel="Position in the codon", ylabel="GC fraction", ylim=(0, 1),
       title="HBB: GC content by codon position")
plt.show()</textarea>
  </div>

  <!-- The Python worker. It is kept in this page, and started from a Blob, so that this one file is all you need. -->
  <script type="text/plain" id="py-worker">
    // Pyodide 314.0.7 (Python 3.14.2), pinned. Since 314.0.0 it must run in a module worker.
    import { loadPyodide } from 'https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.mjs';

    let runId = null;
    const post = (msg) => self.postMessage({ id: runId, ...msg });
    const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
    const writer = (name) => ({
      write: (buf) => { post({ type: name, text: decoders[name].decode(buf, { stream: true }) }); return buf.length; }
    });

    const ready = (async () => {
      const pyodide = await loadPyodide({ env: { MPLBACKEND: 'Agg' } });
      pyodide.setStdout(writer('stdout'));
      pyodide.setStderr(writer('stderr'));
      pyodide.setStdin({ error: true });     // input() cannot wait for typing here, so it raises an error
      await pyodide.runPythonAsync(`
        import sys, io, json, traceback
        from pyodide.code import eval_code_async

        def _figures():
            """the open matplotlib figures as SVG (text stays text), then close them"""
            if 'matplotlib.pyplot' not in sys.modules:
                return []
            import matplotlib.pyplot as plt
            svgs = []
            for num in plt.get_fignums():
                buf = io.StringIO()
                plt.figure(num).savefig(buf, format='svg', bbox_inches='tight')
                svgs.append(buf.getvalue())
            plt.close('all')
            return svgs

        async def _run(code):
            ok = True
            try:
                value = await eval_code_async(code, globals(), filename='<box>')
                if value is not None:
                    print(repr(value))     # show the value of the last line, as a notebook does
            except BaseException as e:
                ok = False
                # show only the lines of the student's own code, not Pyodide's
                tb = traceback.TracebackException.from_exception(e)
                tb.stack = traceback.StackSummary.from_list([f for f in tb.stack if f.filename == '<box>'])
                print(''.join(tb.format()), end='', file=sys.stderr)
            return json.dumps(dict(ok=ok, figures=_figures()))
      `);
      return { pyodide, run: pyodide.globals.get('_run') };
    })();

    self.onmessage = async ({ data }) => {
      if (data.type === 'interrupt-buffer') {       // only sent where the page is cross-origin isolated
        ready.then(({ pyodide }) => pyodide.setInterruptBuffer(data.buffer), () => {});
        return;
      }
      runId = data.id;
      try {
        const { pyodide, run } = await ready;
        // download the packages the code imports (numpy, matplotlib, biopython…) before running it
        await pyodide.loadPackagesFromImports(data.code, { messageCallback: (text) => post({ type: 'status', text }) });
        if (/matplotlib|pyplot/.test(data.code)) {
          await pyodide.runPythonAsync(
            "import matplotlib.pyplot as plt\nplt.rcParams['svg.fonttype'] = 'none'\nplt.show = lambda *a, **k: None");
        }
        post({ type: 'started' });                  // the page's time limit counts from here
        const result = JSON.parse(await run(data.code));
        for (const svg of result.figures) post({ type: 'svg', svg });
        post({ type: 'done', ok: result.ok });
      } catch (e) {
        post({ type: 'stderr', text: String(e.message || e) + '\n' });
        post({ type: 'done', ok: false });
      }
    };
  </script>

  <script type="module">
    /* Runnable Python code boxes, with Pyodide 314.0.7 in a Web Worker, so that the page keeps responding while
       Python works. Every .py-box that holds a <textarea> gets Run, Stop and Reset buttons and an output area.
       All the boxes share one Python session, which starts the first time someone runs or edits a box.
       Option: <div class="py-box" data-plot-alt="…"> describes the box's chart for screen readers. */
    const TIME_LIMIT = 60;     // seconds of running before the code is stopped (downloads do not count)
    const workerSource = document.getElementById('py-worker').textContent;
    // A page opened from a file cannot start a module worker from a blob: address, but it can from a data: address
    const workerUrl = location.protocol === 'file:'
      ? 'data:text/javascript;charset=utf-8,' + encodeURIComponent(workerSource)
      : URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' }));
    // Stop can interrupt Python, and keep its variables, only where the page is cross-origin isolated.
    // Everywhere else (GitHub Pages, for example), Stop ends the worker and the next Run starts a new Python.
    const interrupt = self.crossOriginIsolated ? new Uint8Array(new SharedArrayBuffer(1)) : null;
    let worker = null, current = null, nextId = 0;

    function startWorker() {
      if (worker) return;
      worker = new Worker(workerUrl, { type: 'module' });
      if (interrupt) worker.postMessage({ type: 'interrupt-buffer', buffer: interrupt });
      worker.onmessage = ({ data }) => {
        if (!current || data.id !== current.id) return;
        if (data.type === 'done') current.finish(data.ok);
        else current.onMessage(data);
      };
      worker.onerror = (e) => {                     // the worker itself failed, for example Pyodide could not be downloaded
        e.preventDefault();
        worker.terminate();
        worker = null;                              // the next Run starts a new one
        const job = current;
        if (job) {
          job.onMessage({ type: 'stderr', text: 'Python could not start. Check the internet connection and try again.\n' });
          job.finish(false);
        }
      };
    }

    function runPython(code, onMessage) {
      startWorker();
      if (interrupt) interrupt[0] = 0;
      const id = ++nextId;
      return new Promise((resolve) => {
        let timer = null;
        current = {
          id,
          onMessage: (m) => {
            if (m.type === 'started') timer = setTimeout(() => stop('Stopped after ' + TIME_LIMIT + ' s.'), TIME_LIMIT * 1000);
            else onMessage(m);
          },
          finish: (ok) => { clearTimeout(timer); current = null; resolve(ok); }
        };
        worker.postMessage({ id, code });
      });
    }

    function stop(why) {
      if (!current) return;
      if (interrupt) { interrupt[0] = 2; return; }  // 2 is SIGINT: Python raises KeyboardInterrupt
      const job = current;
      worker.terminate();
      worker = null;
      startWorker();
      job.onMessage({ type: 'stderr', text: '\n' + why + ' Python was restarted, so run the boxes above this one again.\n' });
      job.finish(false);
    }

    let busy = Promise.resolve();                   // boxes run one at a time, in the order they were asked
    for (const box of document.querySelectorAll('.py-box')) {
      const ta = box.querySelector('textarea');
      const original = ta.value;
      ta.rows = Math.max(3, original.split('\n').length + 1);    // show all of the code
      const bar = document.createElement('div');
      bar.className = 'bar';
      bar.innerHTML = '<button type="button" class="run">Run</button><button type="button" class="stop" disabled>Stop</button>' +
                      '<button type="button" class="reset">Reset</button><span class="state" role="status" aria-live="polite"></span>';
      const out = document.createElement('pre');
      out.className = 'out';
      out.tabIndex = 0;                // long output scrolls sideways, so it must be reachable by keyboard
      out.setAttribute('aria-label', 'Output');
      const figs = document.createElement('div');
      figs.className = 'figs';
      box.append(bar, out, figs);
      const [runButton, stopButton, resetButton] = bar.querySelectorAll('button');
      const state = bar.querySelector('.state');

      const run = () => {
        if (runButton.disabled) return;
        runButton.disabled = true;
        out.textContent = '';
        figs.replaceChildren();
        state.textContent = worker ? 'Waiting…' : 'Starting Python (first time only)…';
        busy = busy.then(async () => {
          stopButton.disabled = false;
          const t0 = performance.now();
          const ok = await runPython(ta.value, (m) => {
            if (m.type === 'status') state.textContent = m.text;          // "Loading numpy…"
            else if (m.type === 'svg') {
              const img = new Image();
              img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(m.svg);
              img.alt = box.dataset.plotAlt || 'Chart made by the Python code above';
              figs.append(img);
            } else {
              const span = document.createElement('span');
              if (m.type === 'stderr') span.className = 'err';
              span.textContent = m.text;
              out.append(span);
            }
          });
          state.textContent = (ok ? 'Done' : 'Stopped with an error') + ' (' + ((performance.now() - t0) / 1000).toFixed(1) + ' s).';
          stopButton.disabled = true;
          runButton.disabled = false;
        });
      };

      runButton.addEventListener('click', run);
      stopButton.addEventListener('click', () => stop('Stopped.'));
      resetButton.addEventListener('click', () => { ta.value = original; ta.focus(); });
      ta.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); run(); }
      });
      ta.addEventListener('focus', startWorker, { once: true });   // start downloading Python as soon as someone starts to edit
    }
  </script>
</body>
</html>

How the code works:

  • A module worker. Since version 314.0.0 (June 2026), a worker that runs Pyodide must be a module worker: new Worker(url, { type: 'module' }), with import { loadPyodide } from '…/pyodide.mjs' inside it. Classic workers that use importScripts() no longer load it.
  • One file. The worker’s code sits in the page, in a <script type="text/plain"> element, and starts from a Blob address, so the example is a single file. Opened from a computer’s disk, Chromium refuses a module worker from a Blob, so there the page uses a data: address instead.
  • Packages on demand. loadPackagesFromImports() reads the code’s import lines and downloads what it needs, such as Biopython and matplotlib, before the code runs.
  • Charts come back as SVG, with text kept as text, so they stay sharp at any size.
  • Errors show only the lines of the student’s own code, not Pyodide’s.
  • Stop. The page cannot interrupt Python either, so Stop, or the 60-second time limit, ends the worker and starts a new Python. Variables are lost, and the page says so. Downloads do not count towards the limit.
  • input() cannot wait for typing in a worker, so the boxes make it raise an error. Put the values in the code instead.

Install the packages a class needs

Packages have to be built for WebAssembly before webR or Pyodide can use them, so check before the class that every package you need exists, loads and runs.

webR installs CRAN packages from its own repository, repo.r-wasm.org, which had 24,638 packages for R 4.6 on 15 October 2026. Bioconductor packages [3] come from R-universe, bioc.r-universe.dev, which had 2,402:

// CRAN packages, from the webR repository (the default)
await webR.installPackages(['ggplot2', 'ape'], { quiet: true });

// Bioconductor packages from R-universe, with their CRAN dependencies from the webR repository
await webR.installPackages(['Biostrings'], {
  repos: ['https://bioc.r-universe.dev', 'https://repo.r-wasm.org'],
  quiet: true
});

Both repositories change without notice, and R-universe builds Bioconductor’s development versions, so a package can change between your test and your class. This example checks whether a package, and every package it depends on, has a webR build. It then installs Biostrings and runs it on the HBB sequence.

This example runs R in your browser. Checking packages downloads about 20 MB, and installing Biostrings about 18 MB more.

The check follows each package’s Depends and Imports through both repositories’ lists. A package that passes can still fail for other reasons, so run your own code once before the class.

Code webr-packages.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>webR: check packages, and run Bioconductor's Biostrings</title>
  <style>
    body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
    h1 { font-size: 1.05rem; margin: 0 0 6px; }
    h2 { font-size: 0.95rem; margin: 14px 0 6px; }
    p { margin: 0 0 8px; }
    label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 4px; }
    input, textarea { font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #172033; padding: 5px 8px;
                      border: 1px solid #8a94a6; border-radius: 4px; box-sizing: border-box; width: 100%; }
    textarea { resize: vertical; white-space: pre; overflow-wrap: normal; overflow-x: auto; }
    .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
    button { font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 4px; border: 1px solid #2b56c9; background: #2b56c9; color: #fff; cursor: pointer; }
    button:disabled { opacity: 0.55; cursor: progress; }
    button:focus-visible, input:focus-visible, textarea:focus-visible, pre:focus-visible { outline: 3px solid #b35a00; outline-offset: 2px; }
    .state { font-size: 13px; color: #3b4659; }
    pre { font: 13px/1.4 ui-monospace, Menlo, Consolas, monospace; background: #f4f6fa; border: 1px solid #dde2ea; border-radius: 4px;
          padding: 6px 8px; margin: 0; white-space: pre; overflow-x: auto; }
    pre:empty { display: none; }
    .err { color: #a40000; }
  </style>
</head>
<body>
  <h1>R packages in webR</h1>
  <p>Both parts run R in your browser. Part 1 downloads about 20 MB: R, and the two repositories' lists of packages.
     Part 2 downloads about 18 MB more for Biostrings and the packages it needs, and loading Biostrings takes several seconds.</p>

  <h2>1. Does a package have a webR build, and so does everything it needs?</h2>
  <label for="wanted">Package names, separated by spaces</label>
  <input id="wanted" value="ggplot2 ape Biostrings GenomicRanges DESeq2 msa" spellcheck="false">
  <div class="bar">
    <button type="button" id="check">Check</button>
    <span class="state" id="check-state" role="status" aria-live="polite"></span>
  </div>
  <pre id="check-out" tabindex="0" aria-label="Packages found"></pre>

  <h2>2. Bioconductor's Biostrings, on the HBB coding sequence</h2>
  <label for="code">R code (you can change it)</label>
  <textarea id="code" rows="15" spellcheck="false">suppressPackageStartupMessages(library(Biostrings))
hbb <- DNAString(paste0(    # HBB coding sequence, RefSeq NM_000518.5 bases 51-494
  "ATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGGTGAACGTGGATGAAG",
  "TTGGTGGTGAGGCCCTGGGCAGGCTGCTGGTGGTCTACCCTTGGACCCAGAGGTTCTTTGAGTCCTTTGG",
  "GGATCTGTCCACTCCTGATGCTGTTATGGGCAACCCTAAGGTGAAGGCTCATGGCAAGAAAGTGCTCGGT",
  "GCCTTTAGTGATGGCCTGGCTCACCTGGACAACCTCAAGGGCACCTTTGCCACACTGAGTGAGCTGCACT",
  "GTGACAAGCTGCACGTGGATCCTGAGAACTTCAGGCTCCTGGGCAACGTGCTGGTCTGTGTGCTGGCCCA",
  "TCACTTTGGCAAAGAATTCACCCCACCAGTGCAGGCTGCCTATCAGAAAGTGGTGGCTGGTGTGGCTAAT",
  "GCCCTGGCCCACAAGTATCACTAA"))
letterFrequency(hbb, "GC", as.prob = TRUE)
reverseComplement(hbb)
translate(hbb)
hbs <- replaceLetterAt(hbb, 20, "T")   # the sickle-cell variant, c.20A>T
translate(subseq(hbs, 19, 21))         # codon 7 now codes for valine (V)</textarea>
  <div class="bar">
    <button type="button" id="run">Install Biostrings and run</button>
    <span class="state" id="run-state" role="status" aria-live="polite"></span>
  </div>
  <pre id="run-out" tabindex="0" aria-label="Output"></pre>

  <script type="module">
    const WEBR = 'https://webr.r-wasm.org/v0.6.0/webr.mjs';   // pinned. webR fetches more files of its own, so there is no integrity hash.

    // Two repositories of packages built for webR: CRAN packages, and Bioconductor packages built by R-universe.
    // Both change without notice; R-universe builds Bioconductor's development versions.
    const CRAN_WASM = 'https://repo.r-wasm.org';
    const BIOC_WASM = 'https://bioc.r-universe.dev';

    // For each package: its version in either repository, and any packages it needs (Depends and Imports,
    // followed all the way down) that have no webR build. Missing ones make library() fail even when the
    // package itself installs.
    const CHECK = `
      version <- as.character(getRversion()[, 1:2])     # builds are made for each version of R, here 4.6
      index <- function(repo) available.packages(contriburl = paste0(repo, "/bin/emscripten/contrib/", version))
      cran <- index(cran_url)
      bioc <- index(bioc_url)
      db <- rbind(cran, bioc[!rownames(bioc) %in% rownames(cran), , drop = FALSE])
      known <- c(rownames(db), rownames(installed.packages(priority = "base")))
      needs <- tools::package_dependencies(wanted, db = db, which = c("Depends", "Imports"), recursive = TRUE)
      found <- data.frame(
        package = wanted,
        version = ifelse(wanted %in% rownames(db), db[match(wanted, rownames(db)), "Version"], "-"),
        repository = ifelse(wanted %in% rownames(cran), "repo.r-wasm.org",
                            ifelse(wanted %in% rownames(bioc), "bioc.r-universe.dev", "none")),
        missing = vapply(wanted, function(p) paste(setdiff(needs[[p]], known), collapse = " "), "")
      )
      print(found, row.names = FALSE)
    `;

    let webRPromise = null;
    function startR() {
      if (!webRPromise) {
        webRPromise = (async () => {
          const { WebR } = await import(WEBR).catch(() => {
            throw new Error('R could not be downloaded from webr.r-wasm.org. Check the internet connection, then reload the page.');
          });
          const webR = new WebR();
          await webR.init();
          return webR;
        })();
        webRPromise.catch(() => { webRPromise = null; });
      }
      return webRPromise;
    }

    const seconds = (t0) => ((performance.now() - t0) / 1000).toFixed(1) + ' s';

    // Run R code and show what it prints. Errors, warnings and messages arrive as text, in order.
    async function show(webR, code, env, pre) {
      const shelter = await new webR.Shelter();
      try {
        const result = await shelter.captureR(code, { env, withAutoprint: true, captureStreams: true, captureConditions: false });
        pre.replaceChildren();
        for (const line of result.output) {
          const span = document.createElement('span');
          if (line.type === 'stderr') span.className = 'err';
          span.textContent = line.data + '\n';
          pre.append(span);
        }
      } finally {
        await shelter.purge();
      }
    }

    let queue = Promise.resolve();     // one job at a time in the one R session

    document.getElementById('check').addEventListener('click', (e) => {
      const button = e.currentTarget;
      const state = document.getElementById('check-state');
      const wanted = document.getElementById('wanted').value.split(/[\s,]+/).filter(Boolean);
      if (!wanted.length) { state.textContent = 'Type at least one package name.'; return; }
      button.disabled = true;
      queue = queue.then(async () => {
        const t0 = performance.now();
        try {
          state.textContent = webRPromise ? 'Reading the package lists…' : 'Starting R, then reading the package lists…';
          const webR = await startR();
          await show(webR, CHECK, { wanted, cran_url: CRAN_WASM, bioc_url: BIOC_WASM }, document.getElementById('check-out'));
          state.textContent = 'Done in ' + seconds(t0) + '.';
        } catch (err) {
          state.textContent = 'Failed: ' + err.message;
        } finally {
          button.disabled = false;
        }
      });
    });

    let biostrings = null;             // installed once per visit
    document.getElementById('run').addEventListener('click', (e) => {
      const button = e.currentTarget;
      const state = document.getElementById('run-state');
      button.disabled = true;
      queue = queue.then(async () => {
        const times = [];
        try {
          state.textContent = webRPromise ? 'Running…' : 'Starting R…';
          const webR = await startR();
          let t0;
          if (!biostrings) {
            t0 = performance.now();
            state.textContent = 'Installing Biostrings and the packages it needs…';
            // Bioconductor packages from R-universe; their CRAN dependencies from the webR repository
            biostrings = webR.installPackages(['Biostrings'], { repos: [BIOC_WASM, CRAN_WASM], quiet: true });
            biostrings.catch(() => { biostrings = null; });
            await biostrings;
            times.push('installed in ' + seconds(t0));
          }
          t0 = performance.now();
          state.textContent = 'Running…';
          await show(webR, document.getElementById('code').value, {}, document.getElementById('run-out'));
          times.push('ran in ' + seconds(t0));
          state.textContent = 'Done: ' + times.join(', ') + '.';
          button.textContent = 'Run';
        } catch (err) {
          state.textContent = 'Failed: ' + err.message;
        } finally {
          button.disabled = false;
        }
      });
    });
  </script>
</body>
</html>

What we found on 15 October 2026:

  • These worked: ggplot2, dplyr, ape, seqinr, Biostrings, GenomicRanges, limma and ggtree.
  • DESeq2 does not. It installs from R-universe, but library(DESeq2) fails because locfit, which it needs, has no WebAssembly build in either repository. edgeR needs locfit too. msa and rtracklayer have no build at all.
  • Size and time add up. Biostrings and the packages it needs download 18 MB, and loading Biostrings took about 7 seconds of computing at every visit. ggtree brought 76 MB of dependencies. Keep a class’s packages few and light.
  • For a course that must not change, webR’s rwasm tools can build a fixed set of packages for you to host yourself. We have not tested this.

Pyodide 314.0.7 comes with about 350 packages built for it, including numpy, pandas, matplotlib, scipy, scikit-learn, Biopython, pysam and msprime. micropip installs other packages from PyPI, but only those written purely in Python:

// packages built for Pyodide: one call loads them and everything they need
await pyodide.loadPackage(['numpy', 'pandas', 'biopython', 'micropip']);

// pure-Python packages from PyPI
const micropip = pyodide.pyimport('micropip');
await micropip.install('seaborn');

seaborn and pyfaidx installed this way; scikit-bio did not, because it is not pure Python. pysam’s FastaFile and AlignmentFile work, but its samtools commands, such as pysam.faidx(), crash Pyodide.

Neither webR nor Pyodide can call command-line programs such as BLAST or samtools, and neither suits large data. For those jobs, and for DESeq2, students need a server, such as Galaxy or a university JupyterHub. Some command-line programs have been compiled to run in the browser on their own, as in our Bioinformatics workbench.

Send code to a JupyterLite console or the webR editor

Code boxes suit one step at a time. When students need a console where they can keep typing, or an editor with files and plots, send them to one. JupyterLite’s console and webR’s own editor both take code in their address, so a link or an iframe can open them with your code already running.

This example opens a console from the JupyterLite project's demo site, which downloads about 14 MB when you press Run in the console.

Code jupyterlite-console.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>JupyterLite: a Python console with the code in its address</title>
  <style>
    body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
    h1 { font-size: 1.05rem; margin: 0 0 6px; }
    p { margin: 0 0 8px; }
    label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 4px; }
    textarea { width: 100%; box-sizing: border-box; font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #172033;
               padding: 6px 8px; border: 1px solid #8a94a6; border-radius: 4px; resize: vertical;
               white-space: pre; overflow-wrap: normal; overflow-x: auto; }
    .bar { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin: 6px 0 10px; }
    button { font: inherit; font-size: 14px; padding: 4px 12px; border-radius: 4px; border: 1px solid #2b56c9; background: #2b56c9; color: #fff; cursor: pointer; }
    button:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid #b35a00; outline-offset: 2px; }
    a { color: #2b56c9; }
    #status { font-size: 13px; color: #3b4659; }
    iframe, #placeholder { display: block; box-sizing: border-box; width: 100%; height: 360px; border: 1px solid #c3cbd8; border-radius: 6px; }
    #placeholder { display: flex; align-items: center; justify-content: center; margin: 0; border-style: dashed; color: #5c677a; background: #f4f6fa; }
    [hidden] { display: none !important; }
  </style>
</head>
<body>
  <h1>A Python console from JupyterLite, with the code in its address</h1>
  <p>The code below goes into the console's address and runs when the console opens. Then type more code at the
     console's prompt and press <kbd>Shift</kbd>+<kbd>Enter</kbd>. The console downloads about 14 MB the first time.</p>

  <label for="code">Python code</label>
  <textarea id="code" rows="5" spellcheck="false"># The coding part of HBB exon 1: the first 92 bases of the coding sequence
hbb = "ATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGGTGAACGTGGATGAAGTTGGTGGTGAGGCCCTGGGCAG"
gc = (hbb.count("G") + hbb.count("C")) / len(hbb)
print(f"GC content: {gc:.1%} of {len(hbb)} bases")</textarea>
  <div class="bar">
    <button type="button" id="open">Run in the console</button>
    <a id="link" target="_blank" rel="noopener">Open the same console in a new tab</a>
    <span id="status" role="status" aria-live="polite"></span>
  </div>
  <p id="placeholder">The console opens here.</p>
  <iframe id="console" title="Python console (JupyterLite)" hidden></iframe>

  <script>
    // The console ("REPL") of a JupyterLite site. This is the JupyterLite project's public demo, which changes
    // when its maintainers update it: for a class, use the same path on your own JupyterLite site.
    const REPL = 'https://jupyterlite.github.io/demo/repl/index.html';

    function consoleAddress(code) {
      const options = new URLSearchParams({
        kernel: 'python',     // the Pyodide kernel
        toolbar: '1',         // show the toolbar, with buttons to restart and clear
        showBanner: '0',      // leave out the kernel's welcome text
        code                  // run this when the console opens (add execute: '0' to only fill the prompt)
      });
      return REPL + '?' + options.toString();   // URLSearchParams encodes the code for the address
    }

    const code = document.getElementById('code');
    const link = document.getElementById('link');
    const frame = document.getElementById('console');
    const updateLink = () => { link.href = consoleAddress(code.value); };
    code.addEventListener('input', updateLink);
    updateLink();

    const status = document.getElementById('status');
    let slowTimer = null;
    document.getElementById('open').addEventListener('click', () => {
      document.getElementById('placeholder').hidden = true;
      frame.hidden = false;
      frame.src = consoleAddress(code.value);   // a new address reloads the console, with a new Python
      status.textContent = 'The console is starting. The first time, Python takes about 20 seconds.';
      // The console belongs to another site, so this page cannot see whether Python started.
      // After a minute, say what to check.
      clearTimeout(slowTimer);
      slowTimer = setTimeout(() => {
        status.textContent = 'If the console still shows [*] after a minute, this network may block pypi.org or ' +
          'files.pythonhosted.org, which the console needs. Try another network.';
      }, 60000);
    });
  </script>
</body>
</html>

Points to note:

  • The address is the site’s repl/index.html, with kernel=python and the code in code=, which URLSearchParams encodes. toolbar=1 shows buttons to restart and clear, showBanner=0 hides the welcome text, and, according to JupyterLite’s documentation, execute=0 puts the code at the prompt without running it.
  • Time. The first result took 16 to 21 seconds, while the console downloaded JupyterLite and Pyodide.
  • The demo site changes when its maintainers update it. For a class, use the same path on your own JupyterLite site (next section).
  • It needs PyPI. As the console starts, its Python kernel fetches a small package, comm, from pypi.org. When pypi.org could not be reached, the console never answered. On your own site you can bundle that package, as shown below.
  • Separate storage. In Chromium, files saved in a console embedded in another site are kept apart from those of the same site opened directly.
  • One harmless error. The demo logs an error of its own in the browser’s console, from a part of JupyterLab that fails to start. The console works despite it.

webR’s own app has an editor, a console, a plot pane and a file browser. It opens the files listed after #code= in its address, which makes an “Open in webR” link for a handout or a slide:

// An R script, and the address that opens it in webR's own editor and runs it
const script = [
  'hbb <- "ATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGGTGAACGTGGATGAAGTTGGTGGTGAGGCCCTGGGCAG"',
  'bases <- strsplit(hbb, "")[[1]]',
  'cat("GC content:", round(mean(bases %in% c("G", "C")), 3), "\\n")',
  'barplot(table(bases), main = "Bases in the coding part of HBB exon 1")'
].join('\n');
const files = [{ name: 'hbb.R', path: '/home/web_user/hbb.R', text: script }];
const json = new TextEncoder().encode(JSON.stringify(files));   // UTF-8, so that any character survives
const address = 'https://webr.r-wasm.org/v0.6.0/#code=' +
  encodeURIComponent(btoa(String.fromCharCode(...json))) +
  '&jua';                       // j: the code is JSON, u: not compressed, a: run R scripts straight away
document.getElementById('open-in-webr').href = address;

The page needs a link with that id, such as <a id="open-in-webr">Open in webR</a>. The link opened the script in webR 0.6.0’s editor, ran it and drew the plot in 7 seconds.

Publish a JupyterLite site with your notebooks

For a practical taught as notebooks, publish your own JupyterLite site. Students open a notebook from a link, with the data files it reads, and their changes are saved in their browser. You choose the versions, and nothing runs on a server.

  1. On GitHub, open the jupyterlite/demo repository and choose Use this template. In your new repository, set Settings → Pages → Source to GitHub Actions.
  2. Replace requirements.txt with exact versions:

    jupyterlite-core==0.8.5
    jupyterlite-pyodide-kernel==0.8.6
    jupyter-server==2.21.1
    
  3. Put your notebooks and data files in the content folder.
  4. In .github/workflows/deploy.yml, change the build step to the lines below. They leave out source maps, which cuts the site from 70 MB to 21 MB, and they bundle comm, the package that the Python kernel otherwise fetches from PyPI every time it starts.

          - name: Build the JupyterLite site
            run: |
              pip download --no-deps --only-binary=:all: --dest wheels comm==0.2.3
              jupyter lite build --contents content --output-dir dist --no-sourcemaps \
                --piplite-wheels wheels/comm-0.2.3-py3-none-any.whl
    
  5. Push to main. GitHub builds the site and publishes it at the address shown under Settings → Pages.

We built a site with these versions and commands on 15 October 2026 and tested it in Chromium, but could not run the GitHub workflow itself. With pypi.org out of reach, its console started in 12 to 17 seconds and its notebook ran in 18. Without the bundled comm, the console never started.

Students then open links such as these, with your own GitHub account and repository in place of owner and repository:

https://owner.github.io/repository/notebooks/index.html?path=gc-content.ipynb
https://owner.github.io/repository/lab/index.html?path=gc-content.ipynb
https://owner.github.io/repository/repl/index.html?kernel=python&code=print(1)

The first opens a notebook in the Notebook interface, which is the simplest for students and the easiest to use on a small screen. The second opens the same notebook in JupyterLab, and the third opens a console, as in the example above.

Our test notebook read a FASTA file with Biopython and drew a bar chart. Opening it and running every cell took 18 to 20 seconds and 33 MB on a first visit. Before you rely on a site like this, know where students’ work goes:

  • It stays in the browser. JupyterLite saves notebooks in the browser’s own storage, on that computer and in that browser. Nothing reaches you, and nothing follows the student to another computer. A private window, clearing the browser’s data, a shared computer that wipes its profiles, or Safari, which deletes a site’s storage after seven days of use without a visit, all lose it. Ask students to download their notebooks (File → Download).
  • Their copy wins. Once a student has saved a notebook, their copy is used instead of the site’s, even after you publish a correction. They have to delete their copy (right-click it, then Delete), or you publish the correction under a new name.
  • For R notebooks, use the xeus-r kernel, built with the jupyterlite/xeus-lite-demo template. It serves R and its packages from your own site: the public xeus-r demo downloaded 78 MB and answered in 14 seconds.

Write exercises with feedback in Quarto Live

If you write course pages in Quarto, the Quarto Live extension turns code cells into boxes that webR or Pyodide runs, with exercises, hints, solutions and code that checks answers. You need Quarto to write the page; students need only a browser. This page has an R box, a Python box and an R exercise:

---
title: "GC content in R and Python"
format: live-html
engine: markdown
webr:
  packages:
    - ape
pyodide:
  packages:
    - numpy
---

```{webr}
#| autorun: true
seq <- "ATGCGCGATATTAGCGCGCTAA"
bases <- strsplit(seq, "")[[1]]
gc <- mean(bases %in% c("G", "C"))
cat("GC content (R):", gc, "\n")
ape::as.DNAbin(list(s = bases))
```

```{pyodide}
#| autorun: true
import numpy as np
seq = "ATGCGCGATATTAGCGCGCTAA"
is_gc = np.array([b in "GC" for b in seq])
print("GC content (Python):", is_gc.mean())
```

Write a function that returns the GC content of a sequence.

```{webr}
#| exercise: gc_fun
gc_content <- function(seq) {
  ______
}
gc_content("GGCCAT")
```

::: {.solution exercise="gc_fun"}
```{webr}
#| exercise: gc_fun
#| solution: true
gc_content <- function(seq) {
  bases <- strsplit(seq, "")[[1]]
  mean(bases %in% c("G", "C"))
}
gc_content("GGCCAT")
```
:::

```{webr}
#| exercise: gc_fun
#| check: true
if (isTRUE(all.equal(.result, 4 / 6))) {
  list(correct = TRUE, message = "Correct: 4 of the 6 bases are G or C.")
} else {
  list(correct = FALSE, message = "Not yet: GGCCAT has 4 G or C out of 6 bases.")
}
```

Add the extension to your project, then render the page:

quarto add r-wasm/quarto-live   # the extension, version 0.2.0 when we tested
quarto render genomics.qmd      # with engine: markdown, you need neither R nor Python installed

engine: markdown is not in Quarto Live’s documentation, which uses the knitr or Jupyter engines, but it rendered and ran in our tests, so test it again after you update Quarto or the extension. With Quarto 1.10.18, both boxes printed their results, and a correct answer to the exercise showed “Correct: 4 of the 6 bases are G or C.” The first visit downloaded 50 MB and took 20 seconds. The rendered page comes with a folder of 2.3 MB of files and has Quarto’s own look, not your site’s, so publish it as a separate page. Quarto Live 0.2.0 loads webR 0.6.0 but an older Pyodide, 0.28.1 (Python 3.13); a newer Pyodide needs a new release of the extension.

Stop runaway code without losing the session

None of the examples on this page need cross-origin isolation, a mode in which a page may share memory with its workers (SharedArrayBuffer). Without it, three things are lost: interrupting R or Python while keeping their variables, readline() and input() that wait for typing, and webR’s nested R consoles. The examples replace the first with time limits and a fresh Python, and avoid the other two.

A page is isolated when it arrives with two headers, Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. GitHub Pages cannot send them, but a service worker, a script that the browser runs between the page and the network, can add them. These two files do that. Put them in one folder with the pages that need isolation, and make <script src="isolate.js"></script> the first script of each of those pages.

/* isolate.js: the first script of a page that needs cross-origin isolation (SharedArrayBuffer: interrupting R or
   Python, readline()). Registers isolate-sw.js (in the same folder) and loads the page once more when that service
   worker has taken control. Never reloads more than once per tab; without service workers the page simply stays
   un-isolated, and the code on it should then fall back (webR's PostMessage channel, worker.terminate()). */
(() => {
  const KEY = 'isolate-reloaded';
  const store = (() => { try { return window.sessionStorage; } catch (e) { return null; } })();
  if (self.crossOriginIsolated) { if (store) store.removeItem(KEY); return; }
  if (!self.isSecureContext || !navigator.serviceWorker || !store) return;
  const reload = () => {
    if (store.getItem(KEY)) return;
    store.setItem(KEY, '1');
    location.reload();
  };
  const sw = navigator.serviceWorker;
  sw.register(new URL('isolate-sw.js', document.currentScript.src)).then(() => {
    if (sw.controller) reload();                          // controlled already, but this load had no headers
    else sw.addEventListener('controllerchange', reload, { once: true });
  }).catch((e) => console.warn('isolate.js: no service worker:', e));
})();
/* isolate-sw.js: makes the pages of this folder cross-origin isolated on hosts that cannot send the headers
   themselves (GitHub Pages). Register it with isolate.js. It stores nothing. Adapted from the R workbench's
   isolation-sw.js and coi-serviceworker (MIT). */
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));

self.addEventListener('fetch', (event) => {
  const r = event.request;
  if (r.cache === 'only-if-cached' && r.mode !== 'same-origin') return;
  const url = new URL(r.url);
  if (url.origin === self.location.origin) {
    // pages and workers of this site get the headers; its other files need nothing
    if (r.mode === 'navigate' || r.destination === 'worker') {
      event.respondWith(fetch(r).then((res) => {
        if (res.status === 0 || res.type === 'opaqueredirect') return res;
        const headers = new Headers(res.headers);
        headers.set('Cross-Origin-Opener-Policy', 'same-origin');
        headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
        return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
      }));
    }
    return;
  }
  // another site's file asked for without CORS (an <img>): ask with CORS, so that require-corp lets it in
  if (r.mode === 'no-cors' && url.protocol === 'https:') {
    event.respondWith(fetch(r.url, { mode: 'cors', credentials: 'omit' }).then((res) => {
      const headers = new Headers(res.headers);
      headers.set('Cross-Origin-Resource-Policy', 'cross-origin');
      return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
    }).catch(() => fetch(r)));
  }
});

In our tests these isolated a page on its first visit every time (13 times), after one automatic reload. In an isolated page, webR.interrupt() stopped an endless loop with R’s variables intact, and the Stop button of the Python boxes raised KeyboardInterrupt and kept Python’s variables. In such a page, choose webR’s channel from what the browser reports, so that the page still works where isolation fails:

import { WebR, ChannelType } from 'https://webr.r-wasm.org/v0.6.0/webr.mjs';

const webR = new WebR({
  // shared memory only where the page really is isolated; messages everywhere else
  channelType: self.crossOriginIsolated ? ChannelType.SharedArrayBuffer : ChannelType.PostMessage
});

Two cautions. require-corp blocks anything from another site that does not allow it, which includes many images, videos and ad frames; the worker asks for images again with CORS, but keep isolated pages in a folder of their own, without ads. And where service workers are turned off, as in some managed browsers, the page stays un-isolated, so its code must fall back as these examples do. The R workbench (below) has a fuller version of these files, and coi-serviceworker 0.1.7 does the same job in a single file: it isolated the page in nine of our 10 tests, and once stayed un-isolated until the page was reloaded by hand.

See two whole practicals built this way

Two of our own sites run complete practicals in the browser. They are fuller examples of everything on this page:

  • R workbench runs webR 0.6.0 behind the R console, an RStudio-style editor (with R Markdown, Quarto and Shiny), Jupyter-style notebooks and a terminal, next to the instructions of a practical that takes about 1¾ hours. A service worker makes it cross-origin isolated on GitHub Pages, its install.packages() warns when a package has no webR build, and its repository has Playwright tests with more than 300 checks.
  • Bioinformatics algorithms in Python runs Pyodide behind a notebook of cells in the page, for a three-hour practical with exercises that check themselves and plain-English hints for beginners’ errors. It loads later chapters’ packages in the background, and students can download their answers, and all their code as one Jupyter notebook. It uses Pyodide 0.29.5 in a classic worker, so moving it to Pyodide 314 means switching to a module worker.

Things that catch people out

  • A class downloading at once. Thirty students who each download 15 to 50 MB at the start of a session pull 0.5 to 1.5 GB through the room’s network. Ask them to open the page beforehand: browsers keep webR’s files for 7 days and Pyodide’s for a year.
  • Memory. An R session took about 180 MB of the browser’s memory, and about 500 MB with ggplot2 or Biostrings; Python took 130 to 610 MB. Use one session per page, as the examples do. A phone or Chromebook may reload the tab when memory runs short.
  • Blocked addresses. R needs webr.r-wasm.org and repo.r-wasm.org, Bioconductor packages need bioc.r-universe.dev and r2.ropensci.org, and Python needs cdn.jsdelivr.net. JupyterLite also needs pypi.org and files.pythonhosted.org unless you bundle comm. Try your page from the teaching room’s network before the class.
  • Offline. webR does not start without a network, even after a visit, because it asks its server for a file at every start, in a kind of request (HEAD) that Chromium did not answer from its cache in our tests. Pyodide starts offline only while the page itself is still in the cache, which is 10 minutes for a page on GitHub Pages. Working offline needs your own copies of the runtimes behind a caching service worker, which we have not tested.
  • Packages that change. The webR and R-universe repositories change without notice. Run your pages again shortly before you teach.
  • Updating old pages. Pyodide 314 does not load in a classic worker, so a page built for Pyodide 0.29 or earlier needs a module worker before it can move to 314.
  • Screen readers. The code boxes are plain labelled text areas, without syntax colours. The Tab key moves on as usual, results arrive as text, status messages are announced, and plots have descriptions, but we have not tested the boxes with a screen reader. See accessibility.
  • Phones. At phone width the boxes fit the screen, because code and output scroll inside them, but typing code on a phone is slow.
  • Privacy. Code and data stay in the browser unless the code sends them somewhere, but every visit fetches R or Python from other websites, which see the visitor’s IP address. Say so in your site’s privacy notice.
  • Versions. Every example names exact versions. When you update one, read the release notes and test the page again.

Licences and credits

webR’s own code is under the MIT licence. The R it runs is under the GPL, and webR’s builds of R are distributed under GPL version 3. Pyodide is under the Mozilla Public License 2.0, JupyterLite and its Python kernel under the BSD 3-Clause licence, Quarto and Quarto Live under MIT, PyScript under Apache 2.0, and coi-serviceworker under MIT. Loading them from their own servers copies nothing into your site; if you host copies, keep their licence files with them. R and Python packages keep their own licences. If you publish teaching material that uses Biopython or Bioconductor, cite their papers [2, 3]. The HBB sequence comes from NCBI’s RefSeq database [1].

References

  1. O’Leary NA, Wright MW, Brister JR, et al. (2016). Reference sequence (RefSeq) database at NCBI: current status, taxonomic expansion, and functional annotation. Nucleic Acids Research 44(D1): D733–D745. doi:10.1093/nar/gkv1189
  2. Cock PJA, Antao T, Chang JT, et al. (2009). Biopython: freely available Python tools for computational molecular biology and bioinformatics. Bioinformatics 25(11): 1422–1423. doi:10.1093/bioinformatics/btp163
  3. Huber W, Carey VJ, Gentleman R, et al. (2015). Orchestrating high-throughput genomic analysis with Bioconductor. Nature Methods 12(2): 115–121. doi:10.1038/nmeth.3252