← Blog

Tooling

Pyodide in de browser: een Rotterdamse data-agent playbook

Een analist op de derde verdieping van een kantoor bij de Maashaven heeft deze week 4.200 Portbase ETA-feeds te verwerken, en geen enkele mag haar laptop verlaten.

Jacob Molkenboer· Oprichter · A Brand New Company· 4 aug 2025· 10 min
Open leren scheepsregister met koperen getijdenmeter, groen lint en rode lakzegel op crème linnen bij havenvenster.

Een analist op de derde verdieping van een kantoor bij de Maashaven heeft deze week 4.200 Portbase ETA-feeds te verwerken, en geen daarvan mag haar laptop verlaten. Het klantcontract is glashelder. Geen exports naar BI-dashboards in de VS, geen notebook-runners op gedeelde infra, geen Snowflake-stage, geen 'we beloven dat we het wissen.' Haar tooling: een spreadsheet en een junior die pandas kent.

Dit was de briefing die we in februari kregen van een ports-analytics consultancy van 18 mensen in Rotterdam. Ze wilden een in-portal data-agent die pandas-code kon schrijven, kon uitvoeren en het antwoord kon laten zien, terwijl elke byte aan vessel- en cargo-data binnen het browsertabblad bleef. We hebben het in zeven weken opgeleverd op Pyodide. Het playbook lees je hieronder.

De constraint die de architectuur bepaalde

Havendata is rommelig en politiek. Berth windows, ETA-verschuivingen, demurrage exposure, agent-routing beslissingen: het hoort allemaal niet op een server van een derde partij te belanden zonder een addendum dat door drie partijen is getekend. De klanten van de consultancy (carriers, terminal operators, twee grote shippers) hadden elk hun eigen data-handling clausule onderhandeld. De enige architectuur die voldeed aan de strengste lezing van alle vier de contracten was: data blijft in het tabblad.

Dat klinkt als een no-go voor een LLM-gedreven analytics-tool. Het gangbare patroon is rijen naar een backend sturen, daar pandas draaien, een chart teruggeven. Strip de data en je hebt het product gestript.

Pyodide verandert de rekensom. Python en de scientific stack compileren tegenwoordig schoon genoeg naar WebAssembly om een echte pandas-pipeline in de pagina te laten draaien. Het model blijft op een server staan, maar ziet geen enkele rij. Het schrijft code, de browser voert die uit, de chart rendert lokaal, en het model ziet alleen wat de analist zelf terugplakt in de chat.

Takeaway

De enige schaalbare privacy is geen transmissie. Pyodide maakt dat haalbaar voor een echte analytics-workload, niet alleen voor een demo.

Dat is een bewuste spiegeling van een Hacker News-draadje van vorige week over Postgres, met als clou dat de enige schaalbare delete DROP TABLE is. Zelfde logica, andere laag. Zodra een rij over de lijn is gegaan, is de rest hoop.

Wat er de afgelopen twaalf maanden op PyPI veranderde

Het stuk dat dit project in 2026 (en niet in 2024) shipbaar maakte, is de volwassenwording van WebAssembly-wheels op PyPI. Jarenlang betekende pandas, pyarrow en duckdb naast elkaar in Pyodide krijgen dat je de wheels zelf moest builden met pyodide build, ze op een CDN moest hosten en moest hopen dat niets in de dependency tree ooit een non-WASM dist resolved'e. Begin 2026 begonnen de grote scientific packages emscripten_wasm32-wheels te publiceren naast hun cp39/cp311-builds, en leerde micropip ze tegen de live Pyodide-ABI te resolven zonder custom index.

Dat is het verschil tussen een onderzoeksprototype en iets dat je naar een klant kunt shippen.

Hieronder de loader die het portaal gebruikt. Hij draait één keer bij de eerste page load, en daarna nooit meer tot de wheel-hashes wijzigen.

// 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" });
  }
};

De eerste load is zwaar. Met pandas, pyarrow en duckdb trek je zo'n 18 MB aan gecomprimeerde wheels binnen. We cachen ze bij het eerste bezoek in IndexedDB en de tweede load duurt minder dan een seconde. Meer over caching verderop.

De architectuur van het portaal, van voor naar achter

Er zijn precies vier bewegende delen.

  1. Een statisch portaal (Next.js, geserveerd vanuit een Vercel-deployment in Frankfurt).
  2. Een Pyodide web worker, één per browsertabblad.
  3. Een kleine tool-router op Cloudflare Workers die chatberichten ontvangt, met het LLM praat, en óf tekst óf een Python-tool-call teruggeeft.
  4. Een gesigneerde Portbase-pull, server-to-server, die parquet-bestanden in een per-tenant private bucket dropt. De browser haalt zijn eigen parquet op aanvraag op via een short-lived signed URL.

Let op wat er ontbreekt. Er is geen analytics-backend. Er is geen notebook-server. Er is geen warehouse. De tool-router houdt niets vast behalve session-JWT's.

Wanneer de analist show me ETA slippage for HMM vessels at APMT this week typt, is de round trip als volgt. Het portaal post het bericht naar de router, de router vraagt het model om een tool-call, het model antwoordt met een Python-snippet, de router geeft de snippet terug, de worker draait die tegen de geladen DataFrame, de worker post een kleine JSON-summary plus een optionele thumbnail, de chat rendert het. Op een 2024 MacBook Air is de hele loop 1,8 tot 2,4 seconden, gedomineerd door de model-call.

De Pyodide-worker

De worker is klein. Misschien 180 regels TypeScript en Python samen. Zijn taak: een code-string aannemen, die draaien tegen de geladen DataFrames, en óf een JSON-encoded resultaat teruggeven óf een base64-encoded PNG van een matplotlib-figuur.

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) });
    }
  }
};

Twee dingen zijn hier makkelijk fout te doen.

Ten eerste: draai Pyodide in een worker, nooit op de main thread. Een lange pandas-operatie bevriest anders het tabblad. De analist ziet haar chat-input opeens geen toetsaanslagen meer accepteren en gaat ervan uit dat de app stuk is. Gebruik Comlink, of een handgebouwd postMessage-protocol, om de UI-thread vrij te houden.

Ten tweede: budgetteer je geheugen. Een browsertabblad op een bescheiden laptop heeft zo'n 2 tot 4 GB bruikbare heap voordat het instabiel wordt. Een naïeve df.to_dict() op een frame met vier miljoen rijen crasht het tabblad. We hebben aan de Python-kant een harde guard ingebouwd die weigert meer dan 50.000 rijen tegelijk naar JavaScript te materialiseren.

De wheels-in-IndexedDB cache

Standaard haalt Pyodide bij elke page load opnieuw zijn wheels van de CDN. Prima voor een docs-demo, onacceptabel voor een portaal dat een analist veertig keer per dag opent.

De vorm van de cache is klein: één IndexedDB object store gekeyed op (pyodide_version, package_name, package_version), waarde is de rauwe ArrayBuffer van de wheel. We patchen de fetch van de worker zodat elke .whl-request eerst de cache checkt voordat het netwerk wordt geraadpleegd.

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);
  }
});

Dit brengt de second-load Pyodide-bootup van ongeveer 11 seconden naar ongeveer 900 milliseconden op een warme cache. De analist merkt het.

De COOP/COEP-valkuil

Wil je dat NumPy-operaties SharedArrayBuffer gebruiken (en dat wil je, op de workloads die ons interesseerden is dat ongeveer 2x sneller), dan moet de pagina worden geserveerd met cross-origin isolation. Dat betekent twee 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" }
      ]
    }];
  }
};

Een kleine next.config.js-aanpassing, niet exotisch. Maar het heeft een cascading effect. Elke third-party embed (chat widgets, analytics, zelfs sommige font-CDN's) die geen Cross-Origin-Resource-Policy: cross-origin teruggeeft, weigert te laden. We hebben twee scripts waar de consultancy aan vastzat moeten vervangen. Reken erop. Het COOP/COEP-artikel van web.dev is de canonical reference en is het lezen waard voordat je de headers op een live portaal aanzet.

Waarschuwing

Cross-origin isolation aanzetten breekt stilletjes elke third-party embed die niet meedoet. Audit je script-tags voordat je de headers op productie zet.

De agent loop

Het model draait geen code. Het schrijft code, de worker draait die, en het model krijgt een gestringificeerd resultaat terug dat het kan samenvatten. Dit is de enige veilige vorm, want het model heeft geen manier om data te lekken die het nooit ontvangt.

De system prompt is kort en stabiel over sessies heen.

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

Meer is het niet. Geen few-shot, geen chain-of-thought scaffolding. De agent werkt omdat het oppervlak klein is: lees df, schrijf code, krijg een string terug.

De interessante design choice was: wat krijgt het model te zien nadat de code is uitgevoerd? Wij sturen maximaal terug: de dtypes van een nieuw frame, de eerste 8 rijen afgerond, de shape, en een eventuele matplotlib-figuur als thumbnail, gedownsampled tot 200 px op de lange zijde. Genoeg voor het model om de volgende stap te schrijven. Klein genoeg dat als de klant ooit de logs van de LLM-provider auditteert, daar niets van substance in staat.

Wat we anders zouden doen

Drie dingen.

We begonnen met micropip.install("pandas") in het page-load critical path. Verkeerde keuze. De gebruiker kan het portaal openen, de projectenlijst scannen en de briefing lezen terwijl de worker nog warmloopt. Verplaats zware package-loads naar een requestIdleCallback-achtige hook en je wint het perceived-performance budget gratis terug.

We lieten het model import-statements schrijven in zijn code. Dat werkte tot een sessie probeerde import requests en stilletjes no-op'te omdat de wheel niet in de standaard set van Pyodide zit. Lock het import-oppervlak expliciet: parse de AST, weiger alles buiten een allowlist, en vertel het model dat in de system prompt.

We bewaarden chat-history in dezelfde Cloudflare Worker die het model proxiede. Na drie weken was die chat-history store over tenants heen tot ongeveer 90 MB gegroeid, en een junior consultant vroeg, terecht, of dat telde als data die het tabblad verliet. Dat deed het. We verhuisden de chat-history naar localStorage en persistten alleen de eigen expliciete annotaties van de analist server-side.

Wat je verder moet weten

Pyodide is geen magie en geen freebie. De first-load wheel-cost is reëel, de COOP/COEP-headers breken embeds, en je hebt een worker nodig, geen main-thread shim. Maar de architectuur is nu volwassen genoeg dat een echt analytics-product binnen het tabblad kan draaien, en voor een groep Europese klanten (havens, healthcare, defensieleveranciers, iedereen met een DPA van vóór 2023) is dat de architectuur die de deal wint.

Toen we de in-portal agent voor de Rotterdamse consultancy bouwden, was waar we steeds tegenaan liepen het gat tussen 'Pyodide werkt in een demo' en 'Pyodide overleeft het veertigste tabblad-open van een analist op een dag.' We dichtten het met de IndexedDB-wheel-cache, de worker isolation, en een harde memory-guard aan de Python-kant. Datzelfde patroon is het skelet van de meeste in-browser AI-agents die we shippen voor Europese klanten met strikte data-residency-clausules.

Het kleinste wat je vandaag kunt doen: open de Pyodide REPL in een nieuw tabblad en draai import pandas as pd; pd.DataFrame({"a":[1,2,3]}).describe(). Als de data van jouw portaal het zou overleven om daar verwerkt te worden, ligt de architectuur hierboven op tafel.

Kern

Pyodide is nu volwassen genoeg dat een echt analytics-product binnen het browsertabblad draait, en het enige dat het LLM ooit ziet is de code die het zelf schreef.

FAQ

Draait Pyodide echt pandas in een gewoon browsertabblad?

Ja. Vanaf Pyodide 0.27 laden pandas, pyarrow en duckdb direct vanaf op PyPI gepubliceerde WebAssembly-wheels en draaien ze binnen een web worker. Reken op zo'n 18 MB aan gecomprimeerde wheels bij de eerste load.

Hoe privé is data in het tabblad houden, echt?

Het model ziet alleen de code die het zelf schreef en een kleine samenvatting van het resultaat. Rauwe rijen passeren nooit de netwerkgrens. Als de LLM-provider prompts logt, staat er niets van substance in die logs.

Wat is het praktische geheugenplafond per tabblad?

Een browsertabblad op een bescheiden laptop heeft zo'n 2 tot 4 GB bruikbare heap. Wij plafonderen elke DataFrame-naar-JavaScript-conversie hard op 50.000 rijen om de UI responsief te houden op machines met lagere specs.

Heb ik cross-origin isolation headers nodig?

Alleen als je NumPy-threading via SharedArrayBuffer wilt, wat op gangbare workloads ongeveer 2x sneller is. Zet dan Cross-Origin-Opener-Policy en Cross-Origin-Embedder-Policy op je portaal-responses.

ai agentstoolingarchitectureintegrationsworkflow

Iets bouwen?

Start een project