20.05.26
Create Python Visualizations in the Browser with Matplotlib
Run Python through Verklet, preload matplotlib and numpy in Pyodide, generate a chart, and render the image from the browser filesystem.
Verklet can run supported Python workloads in the browser through
Pyodide. That means a visualization flow can stay inside the user's tab:
boot the runtime, preload matplotlib and numpy, run a Python script,
then read the generated image from the Verklet filesystem.
Use this when you want a notebook-like chart, report preview, or AI-generated visualization without creating a server workspace for every small plot.
Boot Python with plotting packages
Pyodide ships many scientific packages, including NumPy and Matplotlib.
Verklet exposes that through python.preloadPackages, which loads the
packages before each browser Python process starts.
import { Runtime } from '@verklet/sdk';
const runtime = await Runtime.boot({
projectId: 'prj_your_project_id',
backend: 'browser',
persistenceKey: 'matplotlib-demo',
python: {
preloadPackages: ['numpy', 'matplotlib'],
},
});
Use backend: 'browser' when the visualization must stay in the tab.
Use backend: 'auto' only if you also have a server grant configured
and want unsupported Python workloads to promote to Verklet server
runtime.
Write a plotting script
Matplotlib's non-interactive Agg backend is a good default for browser
generation because it writes a file. The script below saves a PNG into
/workdir/chart.png.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 12, 240)
y = np.sin(x) * np.exp(-x / 12)
fig, ax = plt.subplots(figsize=(7, 4), dpi=140)
fig.patch.set_facecolor("#f4f6f7")
ax.set_facecolor("#ffffff")
ax.plot(x, y, color="#0f6b5f", linewidth=2.5)
ax.fill_between(x, y, 0, color="#6fc9b9", alpha=0.28)
ax.axhline(0, color="#9caab5", linewidth=1)
ax.set_title("Damped signal")
ax.set_xlabel("seconds")
ax.set_ylabel("amplitude")
ax.grid(True, color="#d8e0e4", linewidth=0.8)
fig.tight_layout()
fig.savefig("/workdir/chart.png", bbox_inches="tight")
print("wrote /workdir/chart.png")
$ python chart.py ready
Run it and render the result
This React component runs the script once, reads the PNG bytes, and
turns them into an object URL for an <img>.
'use client';
import { Runtime } from '@verklet/sdk';
import { useEffect, useState } from 'react';
const chartScript = String.raw`
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 12, 240)
y = np.sin(x) * np.exp(-x / 12)
fig, ax = plt.subplots(figsize=(7, 4), dpi=140)
fig.patch.set_facecolor("#f4f6f7")
ax.set_facecolor("#ffffff")
ax.plot(x, y, color="#0f6b5f", linewidth=2.5)
ax.fill_between(x, y, 0, color="#6fc9b9", alpha=0.28)
ax.axhline(0, color="#9caab5", linewidth=1)
ax.set_title("Damped signal")
ax.set_xlabel("seconds")
ax.set_ylabel("amplitude")
ax.grid(True, color="#d8e0e4", linewidth=0.8)
fig.tight_layout()
fig.savefig("/workdir/chart.png", bbox_inches="tight")
`;
export function BrowserMatplotlibChart() {
const [src, setSrc] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
let cancelled = false;
let objectUrl: string | undefined;
let runtime: Awaited<ReturnType<typeof Runtime.boot>> | undefined;
async function run() {
runtime = await Runtime.boot({
projectId: process.env.NEXT_PUBLIC_VERKLET_PROJECT_ID!,
backend: 'browser',
persistenceKey: 'matplotlib-demo',
python: { preloadPackages: ['numpy', 'matplotlib'] },
});
await runtime.fs.writeFile('/workdir/chart.py', chartScript);
const proc = await runtime.spawn('python', ['chart.py'], {
cwd: '/workdir',
});
const [stdout, stderr, exitCode] = await Promise.all([
readStream(proc.output),
readStream(proc.stderr),
proc.exit,
]);
if (exitCode !== 0) {
throw new Error(stderr || stdout || `python exited with ${exitCode}`);
}
const bytes = await runtime.fs.readFile('/workdir/chart.png');
if (typeof bytes === 'string') {
throw new Error('Expected binary PNG output');
}
objectUrl = URL.createObjectURL(
new Blob([bytes], { type: 'image/png' }),
);
if (!cancelled) setSrc(objectUrl);
}
run().catch((cause) => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : String(cause));
}
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
void runtime?.teardown();
};
}, []);
if (error) return <p>{error}</p>;
if (!src) return <p>Rendering chart...</p>;
return <img src={src} alt="Damped signal chart generated by Python" />;
}
async function readStream(stream: ReadableStream<string>) {
let output = '';
for await (const chunk of stream) output += chunk;
return output;
}
$ python chart.py ready
The important filesystem steps are:
runtime.fs.writeFile('/workdir/chart.py', chartScript)mounts the Python source.runtime.spawn('python', ['chart.py'], { cwd: '/workdir' })runs the script inside Pyodide.runtime.fs.readFile('/workdir/chart.png')reads the generated image back into JavaScript.
Generate SVG instead
If you want vector output, save SVG and read it as text:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 3.5), dpi=120)
categories = ["Browser", "Server", "Idle"]
values = [74, 18, 8]
colors = ["#0f6b5f", "#4f6171", "#c88a1a"]
ax.bar(categories, values, color=colors)
ax.set_title("Runtime workload split")
ax.set_ylabel("percent")
ax.set_ylim(0, 100)
ax.grid(axis="y", color="#d8e0e4", linewidth=0.8)
fig.tight_layout()
fig.savefig("/workdir/chart.svg", format="svg", bbox_inches="tight")
print("wrote /workdir/chart.svg")
$ python chart-svg.py ready
const svg = await runtime.fs.readFile('/workdir/chart.svg', 'utf8');
Render trusted SVG in an iframe, object tag, or sanitized container. For user-generated or model-generated plots, PNG is usually simpler because the browser treats it as an image rather than markup.
When to use server runtime
Stay in the browser for Pyodide-supported packages and small charts.
Promote to server runtime when the visualization needs native packages,
large datasets, uv, system libraries, subprocesses, or a workspace that
should persist outside browser storage.
The browser-first path is the fast path: no container startup, no server workspace for every chart, and the output is already available to the UI that needs to render it.