Skip to content

A Database with an Embedded WASM Runtime

A pattern for building applications where the code runs where the data lives, in the same OS process, with no hop between them.

The premise

Most modern systems are slow not because they are doing too much work, but because they spend their work talking between processes. The app server calls a driver. The driver crosses a network. The database does a few milliseconds of real work. The result then makes the same trip back through serialization, deserialization, ORM hydration, and template rendering. Of the 80 to 200 ms that a user actually feels, almost none of it was computation.

Distance is the tax. Putting code in the same process as the data is simply the design choice to stop paying the same.

Definition

Co-locating code and data is the discipline of placing application code in the same process as the data it operates on, so every query is a function call.

Five principles follow from this. None of them are negotiable inside such a system. They do constrain the architecture style, but in return they unlock latency, isolation, and operational clarity that a multi-tier stack simply cannot reach.

The five principles

1. Co-location

Application code shares an address space with its primary data store.

Not "on the same host." Not "in the same Kubernetes pod." Not "behind a sidecar proxy." In the same process, on the same memory, with no marshalling between them.

A query is a function call. A write is a function call. The latency between code and data is bounded by L1 / L2 cache, not by Ethernet.

Anti-patternIn-process
App server to DB driver to DB processApp code hosted in the DB process
Sidecar to REST to DBApp and DB share an address space
Lambda + RDSOne binary that owns both

2. One process per service

The unit of deployment is a single process that owns its code, its storage, its HTTP routes, and its lifecycle. Starting it is one syscall. Stopping it is one syscall. Replacing it is a filesystem swap and a kill then spawn.

This is the inverse of twelve-factor item VI, where state is externalized to a backing service. Here, state is the service. Externalizing it would only put the distance back in.

3. Service sovereignty

Each service owns its own data. Nothing is shared by default: no schemas, no tables, no caches, no auth tokens. Two services that need to coordinate communicate over an explicit, network-distant interface, not a shared database.

This is the discipline that microservices were supposed to bring but rarely do, because it is far easier to point ten "microservices" at the same shared RDBMS than to give each one its own. This model enforces sovereignty by architecture itself: the database is the service. You simply cannot share it without merging the services.

4. Local backpressure

Flow control is bounded by the process boundary. A slow client does not back up across a network. It backs up across a function call. A slow disk does not fan out into queue depth on a remote DB. It slows the producer in the same process.

This makes backpressure trivial to reason about. There is no intermediate buffer sitting between producer and consumer that can grow unboundedly. The producer waits when the consumer is slow, for the simple reason that the two of them share the same scheduler.

5. Operational atomicity

The unit of operation (backup, restore, deploy, observe, scale) is the service. Not the cluster, not the namespace, not the schema. One service is one thing you back up, one thing you upgrade, one thing you move.

Multi-service operations (cross-service consistency, distributed transactions) are pushed up to the application layer, where the developer has consciously chosen to fan out, rather than being absorbed into a generic distributed-DB substrate that nobody fully understands.

What this is not

It is not a rejection of distributed systems. Distance is fine. It just should not be the default between an app and its primary data. These services compose into distributed systems via explicit, visible network boundaries: boundaries the developer has chosen, not boundaries inherited from a stack.

It is not a return to the monolith. A monolith merges code. This model separates services but co-locates code with its data. A system built this way can be a monolith (one service), microservices (many small services), or a Self-Contained System per business capability. The architectural pattern is yours to pick. This model is only the floor under it.

It is not "stored procedures." Stored procedures couple code to a specific SQL dialect, run in a constrained single-language environment, and have no access to network or HTTP. These services, on the other hand, run arbitrary WASM modules, in any language that compiles to WASM, with full HTTP routing and a tightly governed outbound path. They are applications, not fragments of a query plan.

Patterns within this model

Monolith

One service. All your data in it. All your code inside it. Deployed as one binary. In-process by definition: nothing crosses a process boundary.

This is the right default for a small team or a young product. It collapses operational complexity down to its minimum without giving up the latency advantages of co-location.

Self-Contained System (service per domain)

One service per business capability. Each owns its UI, its API, and its data. Cross-system coordination is explicit and rare.

This model fits this style very well: each Self-Contained System is one such service, with its own data and its own deployment. The principle of minimizing integration between systems is enforced by the architecture itself.

Microservices

Many small services, each owning a slice of the domain. Each service follows the same rule: code with data in one process. Cross-service calls happen over explicit HTTP.

The hard problem of microservices (that they are often "macroservices in disguise" because they share a database) simply cannot arise here. Services that share data are, by definition, not separate services.

Trade-offs, named and accepted

This model buys you latency, isolation, and operational clarity. In return it pays in three places. Do note these up front, as naming them prevents architectural surprise later.

  1. No cross-service joins in the database. If two services need a joined view, the join happens in application code or in a derived read model. This is a feature, not a bug. It forces the data ownership question right to the surface, where it belongs.

  2. Stronger discipline around service boundaries. A service cannot "just add a table" in someone else's database. Schema changes stay local. Cross-service changes are explicit migrations.

  3. No global queries. "Find all orders across all tenants" does not have a one-line answer. It is a fan-out across services, with all the latency and complexity that implies. For systems where global queries are the dominant workload (analytics, BI), this is the wrong pattern. Better to use a warehouse downstream.

These are the very same trade-offs that a serious microservice architecture makes anyway. This model only makes them honest.

When to use this model

  • Latency-sensitive request paths. Hypermedia apps (HTMX, LiveView, Datastar, Hotwire), real-time UIs, anything where response time is a primary UX feature.
  • Workloads with strong service boundaries. Multi-tenant SaaS, bounded-context business apps, B2B platforms with clear domain separation.
  • Operational simplicity goals. Teams that want one binary to deploy per service, rather than a stack of seven.
  • Strict isolation requirements. Compliance regimes (HIPAA, PCI DSS) where blast radius matters more than aggregate scale.

When not to use this model

  • Analytical or BI workloads where global cross-service queries are the primary use case. Use a warehouse instead.
  • Workloads needing distributed-write consensus (multi-region strong consistency on writes). Use a consensus-based store and accept the latency floor that comes along with it.
  • Existing systems with deep ORM and schema sharing. The migration cost here is real. This model pays back over years. It is not a quick refactor by any means.

Reference implementation: Planck

Planck is a database with an embedded WASM runtime, putting code in the same process as the data it serves. One release tarball gives you three binaries:

  • planck: the database engine. LSM storage, the wire protocol, optional WASM hosting, change streams, and replication, all sitting in one process.
  • workbench: the control plane and web UI on port 2369. Identity, supervision, query editor, deploy receiver, scheduler, and backup / restore all live here.
  • planctl: the CLI for host setup, project scaffolding, deployments, and teardown.

wasmer ships as a shared library next to the binary that loads it, so the WASM runtime is available without any separate install step.

What runs inside one planck process

A single planck process is the whole unit. The layout is as follows:

                ┌──────────────────────────────────────────────┐
                │  planck (one binary, one process)             │
                │                                               │
client  ─tcp───▶│  TCP wire (port 24010)                        │
                │     └─► dispatch ──▶ engine ──▶ storage       │
browser ─http──▶│  HTTP listener (port 3010, optional)          │
                │     └─► WASM runtime (wasmer instance pool)   │
                │            └─► host_request ──▶ dispatch ◀───┐│
                │                                              ││
                │  storage:                                    ││
                │     memtable ─► flush ─► value log + B+tree  ││
                │     WAL (durability)                         ││
                │     secondary indexes                        ││
                │                                              │
                │  change streams:                             │
                │     in-memory ring ─► Watch RPC consumers    │
                │                                              │
                │  replication: continuous WAL shipping        │
                │     from primary to replica                   │
                └──────────────────────────────────────────────┘

  ┌──────────────────────────────────────────────────────────┐
  │  workbench (control plane, separate process)              │
  │     scheduler: backup, gc, WAL truncate, stats,           │
  │        export, import, restore (driven against planck)    │
  └──────────────────────────────────────────────────────────┘

Three pieces matter for the in-process claim:

  • HTTP listener. The browser-facing HTTP listener accepts the incoming request and routes it.
  • WASM runtime. The request goes to a pooled wasmer instance, which calls the module's process(req_ptr, req_len) export. Pool size is configurable (default min: 2, max: 8, with autoscale off by default).
  • Change streams. Writes land in an in-memory ring after WAL ack and before the reply goes back to the client. Consumers tail it through the Watch RPC. Replication is a separate thing altogether. It is a continuous primary to replica WAL ship.

Operational tasks (backup, gc, WAL truncate, stats, export, import, restore) are scheduled and triggered from workbench against each planck it supervises. They are not part of the engine's own runtime.

The hop that is not there

When the WASM module needs data, the DB call does not leave the process at all. The WASM client serializes a Packet, calls the host_request extern, and the host then runs the same dispatch path that the TCP server uses. No socket. No kernel buffer. No fiber yield in the hot path. Just two bounded memory copies in and out, and the result is back in the module's address space.

The same dispatch path also serves clients connecting over TCP, so the wire protocol does not change depending on whether the caller is local or remote. Workbench, planctl, and the standalone client all talk the same protocol.

Storage: LSM, in plain terms

Planck's storage is LSM-tree style, with a few intentional choices:

  • Skiplist memtable per store. Writes land here first and are immediately visible to reads.
  • WAL append for durability. Every write op is appended to the per-host WAL before being applied. A background fsync runs at durability.flush_interval_in_ms. The default of 1000 ms trades a one-second crash window for write throughput. Smaller intervals tighten the window, but at a cost.
  • Value log for large payloads. When the memtable fills up, a background flush ships its contents to an append-only segmented value log (default 1 GiB per segment) and the primary index gets a new pointer per key. The flush is the main pause point.
  • B+tree primary index mapping a 128-bit primary key to a value log pointer (segment_id, offset). Disk-resident, with an in-memory page cache.
  • Secondary indexes, one B+tree per indexed field, mapping field value to primary key. The query path uses these to avoid full-store scans when the filter touches an indexed column.

Old value-log segments accumulate dead bytes as updates and deletes invalidate entries. The workbench-driven gc task compacts value logs by a dead-ratio threshold (gc.dead_ratio, default 30%) and skips the tail segment, the one currently being written, so that foreground writes are not contended.

The WAL truncate task respects durability.log_archive.retain_logs_days. In case log archiving is enabled, rotated segments are shipped to the archive directory first, and only then are old segments truncated. Backup tooling consumes the archive.

Configuration is YAML

Two files. Both YAML. No environment variable overrides.

  • db.yaml for the DB: storage tuning, the TCP wire, durability, replication, change streams.
  • service.yaml for the WASM runtime: identity, HTTP hosting, the outbound upstream allowlist.

Both files are optional. Missing files simply mean safe defaults. The upstream allowlist in service.yaml is the only way out: a WASM module's host_call_service checks the named upstream against the list before making the call. There is no generic "reach any URL" path.

TLS by default

For the mono deployment, planck defaults to TLS 1.3 with a self-signed certificate. There are two escape hatches, both explicit:

  • Bring your own certificate (tls.cert_file / tls.key_file).
  • Disable TLS entirely when planck sits behind a proxy that terminates TLS for you.

The wire protocol on port 24010 follows the same TLS 1.3 default.

Workbench is the control plane

Workbench is the single control plane for the host. It owns identity, the supervised services (planck, the system DB, your deployed apps), the deploy receiver, the scheduler views, query editing, and backup or restore. The system DB it runs on top of is just another planck instance, on its own port. As such, you operate the host by talking to workbench, not by orchestrating the binaries by hand.

Do note that you do not need Planck for this. The principles stand on their own. Planck only removes the friction.

Further reading

  • Self-Contained Systems, http://scs-architecture.org
  • Beyond the Twelve-Factor App, useful contrast. This model inverts factor VI.
  • In-Process Databases, SQLite's positioning paper.
  • PostgreSQL Background Workers, the closest thing in a mainstream RDBMS to running app code next to the engine.

Zero Distance Architecture is a public pattern. The text of this document is licensed CC BY 4.0. Copy it, quote it, build on it.