← Blog

Tooling

Pyodide in the browser: a Rotterdam ports data-agent playbook

An analyst on the third floor of an office near the Maashaven has 4,200 Portbase ETA feeds to slice this week, not one allowed to leave her laptop.

Jacob Molkenboer· Founder · A Brand New Company· 4 Aug 2025· 10 min
Open leather ledger with brass tide dial, green ribbon and red wax seal on a cream linen desk by a port window.

An analyst on the third floor of an office near the Maashaven has 4,200 Portbase ETA feeds to slice this week, and not one of them is allowed to leave her laptop. The client contract is explicit. No exports to BI dashboards in the U.S., no notebook runners on shared infra, no Snowflake stage, no "we promise we delete it." Her tooling is a spreadsheet and a junior who knows pandas.

This is the brief we got from an 18-person ports-analytics consultancy in Rotterdam in February. They wanted an in-portal data agent that could write pandas code, run it, and show the answer, while every byte of vessel and cargo data stayed inside the browser tab. We shipped it in seven weeks on Pyodide. The playbook is below.

The constraint that picked the architecture

Ports data is messy and political. Berth windows, ETA shifts, demurrage exposure, agent-routing decisions, none of it is supposed to land on a third-party server without an addendum signed by three parties. The consultancy's clients (carriers, terminal operators, two large shippers) had each negotiated their own data-handling clause. The only architecture that satisfied the strictest reading of all four contracts was: data stays in the tab.

That sounds like a non-starter for an LLM-driven analytics tool. The usual pattern is to send rows to a backend, run pandas there, return a chart. Strip the data out and you have stripped the product.

Pyodide changes the math. Python and the scientific stack now compile to WebAssembly cleanly enough that a real pandas pipeline runs in the page. The model still lives on a server, but it never sees a row. It writes code; the browser runs it; the chart renders locally; the model only sees what the analyst chooses to paste back into the chat.

Takeaway

The only scalable privacy is no transmission. Pyodide makes that achievable for a real analytics workload, not just a demo.

That is a deliberate mirror of a thread on Hacker News last week about Postgres, where the punchline was that the only scalable delete is DROP TABLE. Same logic, different layer. Once a row has crossed the wire, the rest is hope.

What changed on PyPI in the last twelve months

The piece that made this project shippable in 2026 (and not in 2024) is the maturation of WebAssembly wheels on PyPI. For years, getting pandas, pyarrow and duckdb to coexist inside Pyodide meant building the wheels yourself with pyodide build, hosting them on a CDN, and praying nothing in the dependency tree ever resolved a non-WASM dist. By early 2026 the major scientific packages started publishing emscripten_wasm32 wheels alongside their cp39/cp311 builds, and micropip learned to resolve them against the live Pyodide ABI without a custom index.

That is the difference between a research prototype and something you can ship to a client.

Here is the loader the portal uses. It runs once on first page load, then never again until the wheel hashes change.

// worker.ts
import { loadPyodide } from "pyodide";

let pyodide;

self.onmessage = async (e) => {
  if (e.data.type === "boot") {
    pyodide = await loadPyodide({
      indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.0/full/"
    });
    await pyodide.loadPackage("micropip");
    const micropip = pyodide.pyimport("micropip");
    await micropip.install([
      "pandas==2.2.3",
      "pyarrow==18.1.0",
      "duckdb==1.1.3"
    ]);
    self.postMessage({ type: "ready" });
  }
};

The first load is heavy. With pandas, pyarrow and duckdb you are pulling about 18 MB of compressed wheels. We cache them in IndexedDB on first visit and the second load is sub-second. More on caching below.

The portal architecture, end to end

There are exactly four moving parts.

  1. A static portal (Next.js, served from a Vercel deployment in Frankfurt).
  2. A Pyodide web worker, one per browser tab.
  3. A small tool-router running on Cloudflare Workers that receives chat messages, talks to the LLM, and returns either text or a Python tool call.
  4. A signed Portbase pull, server-to-server, that drops parquet files into a per-tenant private bucket. The browser fetches its own parquet on demand using a short-lived signed URL.

Notice what is missing. There is no analytics backend. There is no notebook server. There is no warehouse. The tool-router holds nothing but session JWTs.

When the analyst types show me ETA slippage for HMM vessels at APMT this week, the round trip is: portal posts the message to the router, the router asks the model for a tool call, the model replies with a Python snippet, the router returns the snippet, the worker runs it against the loaded DataFrame, the worker posts a small JSON summary plus an optional thumbnail, the chat renders it. On a 2024 MacBook Air the whole loop is 1.8 to 2.4 seconds, dominated by the model call.

The Pyodide worker

The worker is small. Maybe 180 lines of TypeScript and Python combined. The job is to take a code string, run it against the loaded DataFrames, and return either a JSON-encoded result or a base64-encoded PNG of a matplotlib figure.

self.onmessage = async (e) => {
  if (e.data.type === "run") {
    try {
      const result = await pyodide.runPythonAsync(e.data.code);
      const json = result?.toJs
        ? result.toJs({ dict_converter: Object.fromEntries })
        : result;
      self.postMessage({ type: "result", id: e.data.id, json });
    } catch (err) {
      self.postMessage({ type: "error", id: e.data.id, msg: String(err) });
    }
  }
};

Two things matter here that are easy to get wrong.

First, you must run Pyodide in a worker, never on the main thread. A long pandas operation will freeze the tab otherwise. The analyst will see her chat input stop accepting keystrokes and assume the app is broken. Use Comlink, or a hand-rolled postMessage protocol, to keep the UI thread free.

Second, you have to budget memory. A browser tab on a modest laptop has roughly 2 to 4 GB of usable heap before things get unstable. A naive df.to_dict() on a four-million-row frame will crash the tab. We added a hard guard in the Python side that refuses to materialise more than 50,000 rows to JavaScript at once.

The wheels-in-IndexedDB cache

By default Pyodide refetches its wheels from the CDN on every page load. That is fine for a docs demo and unacceptable for a portal an analyst opens forty times a day.

The shape of the cache is small: one IndexedDB object store keyed on (pyodide_version, package_name, package_version), value is the raw ArrayBuffer of the wheel. We patch the worker's fetch so any .whl request checks the cache before the network.

const wheelCache = await openIDB("pyodide-wheels", 1);

self.fetch = new Proxy(self.fetch, {
  apply: async (target, _, args) => {
    const url = args[0];
    if (typeof url === "string" && url.endsWith(".whl")) {
      const hit = await idbGet(wheelCache, url);
      if (hit) return new Response(hit);
      const res = await Reflect.apply(target, self, args);
      const buf = await res.clone().arrayBuffer();
      await idbPut(wheelCache, url, buf);
      return new Response(buf);
    }
    return Reflect.apply(target, self, args);
  }
});

This brings second-load Pyodide bootup from about 11 seconds to about 900 milliseconds on a warm cache. The analyst notices.

The COOP/COEP gotcha

If you want NumPy operations to use SharedArrayBuffer (you do, it is roughly 2x faster on the workloads we cared about), the page must be served with cross-origin isolation. That means two response headers.

// next.config.js
module.exports = {
  async headers() {
    return [{
      source: "/(.*)",
      headers: [
        { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
        { key: "Cross-Origin-Embedder-Policy", value: "require-corp" }
      ]
    }];
  }
};

This is a small next.config.js edit, not exotic. But it has a cascading effect. Any third-party embed (chat widgets, analytics, even some font CDNs) that does not return Cross-Origin-Resource-Policy: cross-origin will refuse to load. We had to swap out two scripts the consultancy was attached to. Plan for that. web.dev's COOP/COEP article is the canonical reference and worth reading before you flip the headers on a live portal.

Warning

Turning on cross-origin isolation will silently break any third-party embed that does not opt in. Audit your script tags before you ship the headers.

The agent loop

The model does not run code. It writes code, the worker runs it, and the model gets back a stringified result it can summarise. This is the only safe shape, because the model has no way to leak data it never receives.

The system prompt is short and stable across sessions.

You are a data assistant for a ports-analytics team. The user has a pandas DataFrame named df loaded with Portbase ETA records for the current week. Reply with one of: a sentence, a code block in a python fence, or a question. When you write code, assume df is already in memory. Do not invent column names; if you do not know a column, ask.

Portal system prompt, v3

That is it. No few-shot, no chain-of-thought scaffolding. The agent works because the surface area is tiny: read df, write code, get a string back.

The interesting design choice was deciding what the model gets to see after the code runs. We return at most: the dtypes of any new frame, the first 8 rows rounded, the shape, and any matplotlib figure as a thumbnail downsampled to 200 px on the long side. That is enough for the model to write the next step. It is small enough that if the client ever audits the LLM provider's logs, there is nothing of substance in them.

What we would do differently

Three things.

We started with micropip.install("pandas") in the page-load critical path. Wrong call. The user can open the portal, scan the projects list, and read the brief while the worker is still warming. Move heavy package loads behind a requestIdleCallback-style hook and you reclaim the perceived-performance budget for free.

We let the model write import statements in its code. That worked until a session tried to import requests and silently no-op'd because the wheel is not in Pyodide's standard set. Lock the import surface explicitly: parse the AST, reject anything outside an allowlist, and tell the model in the system prompt.

We stored chat history in the same Cloudflare Worker that proxied the model. After three weeks the chat-history store had grown to about 90 MB across tenants and a junior consultant asked, reasonably, whether that counted as data leaving the tab. It did. We moved chat history into localStorage and only persisted the analyst's own explicit annotations server-side.

The closing thing to know

Pyodide is not magic and it is not free. The first-load wheel cost is real, the COOP/COEP headers will break embeds, and you need a worker, not a main-thread shim. But the architecture is now mature enough that a real analytics product can run inside the tab, and for a class of European customers (ports, healthcare, defence supplier networks, anyone with a DPA that pre-dates 2023) that is the architecture that wins the deal.

When we built the in-portal agent for the Rotterdam consultancy, the thing we kept running into was the gap between "Pyodide works in a demo" and "Pyodide survives an analyst's fortieth tab-open of the day." We closed it with the IndexedDB wheel cache, the worker isolation, and a hard memory guard inside the Python side. That same pattern is the bones of most of the in-browser AI agents we ship for European clients with strict data-residency clauses.

The smallest thing you can do today: open the Pyodide REPL in a new tab and run import pandas as pd; pd.DataFrame({"a":[1,2,3]}).describe(). If your portal's data could survive being processed there, the architecture above is on the table.

Key takeaway

Pyodide is now mature enough that a real analytics product runs inside the browser tab, and the only thing the LLM ever sees is the code it wrote.

FAQ

Does Pyodide really run pandas in a normal browser tab?

Yes. As of Pyodide 0.27 pandas, pyarrow and duckdb load directly from PyPI-published WebAssembly wheels and run inside a web worker. Expect about 18 MB of compressed wheels on first load.

How private is keeping the data in the tab, really?

The model only sees the code it wrote and a small summary of the result. Raw rows never cross the network boundary. If the LLM provider logs prompts, there is nothing of substance in those logs.

What is the practical memory ceiling per tab?

A browser tab on a modest laptop has roughly 2 to 4 GB of usable heap. We hard-cap any DataFrame-to-JavaScript conversion at 50,000 rows to keep the UI responsive on lower-spec machines.

Do I need cross-origin isolation headers?

Only if you want NumPy threading via SharedArrayBuffer, which is roughly 2x faster on common workloads. Set Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy on your portal responses.

ai agentstoolingarchitectureintegrationsworkflow

Building something?

Start a project