Every load-testing tool eventually faces the same pressure: someone wants a protocol you don't ship. A team runs on NATS, or MQTT, or a bespoke binary framing over TCP, and now your options are "wait for us to add it" or "vendor a fork." JMeter answered with a sprawling plugin zoo behind a JVM. k6 answered with xk6, which recompiles the binary. loadr took a different bet — extend the tool at runtime, in the language of your choice, without rebuilding the core and without a JVM. This is the story of how that machinery came together and why the built-in database drivers eventually moved out of the binary.
Three ways in
The plugin system landed in a single commit on 2026-06-12 — a3b62bb, "feat(plugins): WASM (WIT) + native (abi_stable) plugin system, registry, four example plugins." From day one there were two authoring mechanisms, and a third arrived four days later. They exist because the trade-offs are genuinely different.
WASM components (WIT). The interface is defined once in crates/loadr-plugin-api/wit/loadr.wit as package loadr:plugin@0.1.0. It declares two worlds — loadr-plugin for extractors and loadr-assertion-plugin for assertions — plus a loadr-meta-probe world every plugin exports so the host can identify it before committing to a role:
interface extractor {
extract: func(body: list<u8>, headers: list<tuple<string, string>>,
config: string) -> option<string>;
}
interface assertion {
record verdict { pass: bool, detail: string }
check: func(status: s64, body: list<u8>, headers: list<tuple<string, string>>,
duration-ms: f64, config: string) -> verdict;
}
WASM is the right tool for extractors and assertions: one .wasm runs on every platform, it's fully sandboxed (no filesystem or network unless granted), and it can be written in any language with component tooling. The cost is the sandbox boundary and the copy in and out — fine for pulling a value out of a response body, wrong for a hot protocol driver.
Native libraries (abi_stable). For protocols, outputs and services — where you want raw throughput and real system access — plugins are `cdylib`s loaded via abi_stable. The loader (crates/loadr-plugin-api/src/native.rs) uses lib_header_from_path to validate the plugin's type layout against the host's before any call is made, then checks our own LOADR_PLUGIN_ABI_VERSION. An incompatible plugin fails loudly with a useful error instead of corrupting memory. The engine only ever talks to plugins through object-safe traits — PluginExtractor, PluginAssertion, ServicePlugin, and the protocol handler — so the WASM and native machinery both disappear behind the same seam.
The plain C ABI. abi_stable's layout handshake is a compile-time Rust-to-Rust contract; no other language can reproduce it. So on 2026-06-16, commit 42fa02e added a frozen, minimal C ABI (src/cabi.rs, LOADR_C_ABI_VERSION = 1). A protocol plugin in any language that emits a C shared library exports exactly four symbols:
uint32_t loadr_plugin_abi_version(void);
uint8_t *loadr_plugin_info(size_t *out_len);
uint8_t *loadr_plugin_execute(const uint8_t *req, size_t req_len, size_t *out_len);
void loadr_plugin_free(uint8_t *ptr, size_t len);
Requests and responses cross as UTF-8 JSON (FfiRequest / FfiResponse, body base64-encoded), all buffers are plugin-owned so allocation stays symmetric, and execute must be thread-safe because the host fans it out across every worker thread. The host auto-detects which ABI a library speaks by probing for loadr_plugin_abi_version, so C-ABI and abi_stable plugins coexist transparently.
To prove it wasn't Rust-only, commit 4bc4f08 shipped examples/plugins/go-echo — a protocol plugin written in Go, built with go build -buildmode=c-shared, serving a goecho:// scheme via //export directives and encoding/json. There's a dependency-free C sibling (c-echo) too. A recorded demo of the Go plugin driving live traffic went up two days later in #22.
Migrating the databases out of the core
Once native protocol plugins worked, the built-in database and messaging drivers had no reason to stay in the binary. Bundling them meant every user carried Mongo, SQL and Redis code — and their transitive C/OpenSSL dependencies — whether they touched a database or not. Over 2026-06-15 to 06-16 they moved out, one at a time:
| Crate | Commit | Notes |
|---|---|---|
loadr-plugin-mongo | 4350f10 | First native protocol plugin; introduced scheme routing |
loadr-plugin-postgres / -mysql | 485f6f0, split in 6a0a9be | Shipped as one SQL plugin, then split into two |
loadr-plugin-redis | 3264a4d | Hand-rolled RESP over TCP — no redis crate, no OpenSSL |
loadr-plugin-kafka | be51f18 | Native Apache Kafka protocol |
loadr-plugin-elasticsearch | 6954f50 | |
loadr-plugin-rabbitmq | 60a9d4f | AMQP 0.9.1 via pure-Rust lapin, TLS through rustls only |
The Redis migration is the clearest example of the discipline this enforced. Rather than lift the redis crate into the plugin, we hand-rolled RESP encode/decode over raw TCP so the cdylib cross-compiles cleanly to all five release targets with no system libraries. Same story for RabbitMQ: pure-Rust lapin, rustls, no C deps. The core lost the modules entirely — cargo tree -p loadr-cli no longer pulls anything Redis — and the loaded plugin emits its own redis_reqs / redis_req_duration metric family instead of being special-cased in the engine.
Install, index, verify, publish
Migrating drivers out is only viable if getting them back is one command. The resolver and downloader landed in 4a9fd65. loadr plugin install <name> resolves a short name against a JSON index (plugins/index.json), picks the artifact matching the running host's target triple and ABI, downloads it, sha256-verifies it against the index, unpacks it and hands the directory to the registry. All network I/O goes through a Fetcher seam, so resolution and verification are unit-tested without touching the network.
The index is produced, not hand-maintained. publish-plugins.yml fires on a plugin-v* tag: it builds every plugin cdylib for all shipped targets, uploads the archives to a GitHub Release with per-file SHA-256 sums and SLSA build provenance (actions/attest-build-provenance), then regenerates plugins/index.json and commits it back to main. Two hard-won fixes shape that pipeline: discovery skips WASM plugins so they don't get force-built as cdylibs (0e3a3af), and plugin releases are pinned so they can never hijack releases/latest and break CLI downloads (38783a4) — the enterprise forces GITHUB_TOKEN read-only, so the upload and index commit-back run on an org PAT.
Where it landed
Four plugin release trains later (plugin-v1.0.0 through plugin-v1.3.0, 2026-06-15 to 07-02), the signed index carries close to 30 published native plugins across five roles: protocol drivers (the databases above, plus MQTT, NATS, Cassandra), outputs (Datadog, CloudWatch, OTLP, S3 archive, JUnit, Slack), request signers (SigV4, HMAC, OAuth2), extractors and assertions (CSS-select, XPath, JSON-schema, protobuf-decode, JWT-decode) — the WASM ones riding the WIT interface, the rest native. None of it is in the core binary. That's the whole point: the download stays small, the dependency graph stays honest, and the protocol you need is a plugin install away — or four exported C symbols, if we haven't written it yet.