Skip to content

Running the same server in the browser (Postgres in WASM)

Before you read further, you can see it here working:

in-browser server demo.

One of the more surprising things you can build with Emi is a full HTTP server — routes, typed handlers, a Postgres database — that runs entirely inside a browser tab, with no network and no backend process. And it is not a separate, browser-only rewrite of your code: it is the exact same Go handlers, the exact same SQL, and the exact same generated request/response classes you ship to a real server.

This document walks through how the examples/in-browser-server example is put together, what gets compiled to WebAssembly, and why the same code can talk to Postgres whether it is running on a server or in a browser.

Compile your Emi-generated Go server to GOOS=js GOARCH=wasm, give it an HTTP router that lives in memory, and back its database calls with pglite — a real Postgres compiled to WebAssembly that runs in the browser. The browser then “fetches” from a server that is sitting in the same tab.

┌───────────────────────── browser tab ─────────────────────────┐
│ │
│ main.js ──localFetch()──► Go WASM (net/http ServeMux) │
│ │ │
│ your handlers (UserCrud.go) │
│ │ pgx-style Database iface │
│ WasmDatabase ──queryDatabase()──► │
│ │ │
│ database-bridge.js ──► pglite (WASM) │
│ (real Postgres) │
└────────────────────────────────────────────────────────────────┘

The remarkable part: everything from “your handlers” down to the SQL string is byte-for-byte identical to the code that runs on the real Gin server in cmd/server.

The whole design rests on a single small interface. The handlers never import pgx directly or hold a concrete connection — they hold a Database:

internal/CommonDb.go
//
// Database is the subset of pgx methods the modules use. Implementations
// can wrap a real *pgx.Conn (passthrough) or interpose a rewriter such as
// a WASM module that mutates SQL/args before they reach Postgres.
type Database interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error)
}

Because this is exactly the shape of *pgx.Conn, the server build just passes a live connection. The browser build passes a WasmDatabase that forwards every query to JavaScript. The business logic above does not know or care which one it got:

// internal/UserCrud.go — runs verbatim on server AND in the browser
func (m *UserModule) Create(c defs.CreateUserActionRequest) (*defs.CreateUserActionResponse, error) {
ctx := context.Background()
var id int
row := m.DB.QueryRow(ctx,
`INSERT INTO users (first_name, last_name, birth_date)
VALUES ($1, $2, $3)
RETURNING id`,
c.Body.FirstName, c.Body.LastName, c.Body.BirthDate,
)
if err := row.Scan(&id); err != nil {
return nil, err
}
return &defs.CreateUserActionResponse{
Payload: defs.CreateUserActionRes{
Id: id,
FirstName: c.Body.FirstName,
LastName: c.Body.LastName,
BirthDate: c.Body.BirthDate,
},
}, nil
}

That $1, $2, $3 parameterized SQL is hand-written, and the typed CreateUserActionRequest / CreateUserActionResponse DTOs around it are generated by Emi from the module spec. This is the design philosophy of the whole stack: you write the SQL, Emi gives you the typed envelope, and the same statement runs everywhere.

The actions are defined once, in in-browser-server.emi.yml. A trimmed view:

actions:
- name: createUser
description: Insert a new user row and return the created record.
url: /users
method: post
in:
fields:
- name: firstName
type: string
- name: lastName
type: string
- name: birthDate
type: string
out:
fields:
- name: id
type: int
- name: firstName
type: string
- name: lastName
type: string
- name: birthDate
type: string
- name: listUsers
url: /users
method: get
out:
fields:
- name: users
type: array
fields:
- name: id
type: int
# ...
- name: deleteUser
url: /users
method: delete
# ...

From this single spec, Emi generates both the Go server-side handlers and the JavaScript client classes. There is no second source of truth.

The build: one spec, two outputs, three artifacts

Section titled “The build: one spec, two outputs, three artifacts”

The Makefile shows the full pipeline:

def:
../../emi go --path ./in-browser-server.emi.yml --output ./internal/defs --pkg defs --tags skip-cli,split-gin,no-client,skip-wasm-gin && \
../../emi js --path ./in-browser-server.emi.yml --output ./browser/gen --tags include-ext
wasm:
GOOS=js GOARCH=wasm go build -o ./browser/in-browser-server.wasm ./cmd/wasm/main.go
server:
go build -o ./browser/server ./cmd/server/main.go
  • emi go generates the typed Go handlers (defs/) — both a Gin binding and a plain net/http binding for every action.
  • emi js generates the matching typed client classes (browser/gen/).
  • make wasm compiles the browser entrypoint to a .wasm file.
  • make server compiles the real Gin server from the same packages.

The only thing that differs between the two binaries is which main.go is picked up, and that is decided entirely by Go build tags.

The server entrypoint is unremarkable — it is plain Gin:

cmd/server/main.go
//go:build !wasm
func main() {
g := gin.Default()
defs.SubstringActionGin(g, internal.SubstringAction)
g.Run(":9123")
}

The WASM entrypoint builds a real net/http.ServeMux, registers the same handler implementations, and then “lifts” the mux into the browser:

cmd/wasm/main.go
//go:build js && wasm
func main() {
// queryDatabase is injected by browser/database-bridge.js and is backed by
// an in-browser pglite instance.
queryFunc := js.Global().Get("queryDatabase")
var conn internal.Database = internal.NewWasmDatabase(queryFunc)
users := &internal.UserModule{DB: conn}
users.EnsureSchema(context.Background())
mux := http.NewServeMux()
defs.SubstringActionHttp(mux, internal.SubstringAction)
defs.CreateUserActionHttp(mux, users.Create)
defs.ListUsersActionHttp(mux, users.List)
defs.DeleteUserActionHttp(mux, users.Delete)
emigo.LiftWasmServer(mux, nil)
// Keep the Go runtime alive so the exposed callback stays callable.
select {}
}

Note defs.CreateUserActionHttp(mux, users.Create) — the generated handler is wired to the same users.Create method the server would use. The transport (Gin vs net/http) is generated; the logic is shared.

emigo.LiftWasmServer is the small bridge that makes an in-memory Go router callable from JavaScript. It exposes a single global function, window.handleWasmRequest(method, url, body, headersJSON), and runs each call through the mux using httptest:

// emigo/WasmRouter.go (build tag: wasm)
req := httptest.NewRequest(method, url, strings.NewReader(bodyStr))
// ...set headers...
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req) // real routing, real handler, real ResponseWriter
res := rec.Result()

This is the genuine net/http request loop — real routing, a real ResponseWriter, real status codes and headers. The browser is just feeding it requests one at a time.

There is one subtlety worth calling out, because it is the crux of making async Postgres work inside synchronous WASM. A Go function invoked from JS runs synchronously and never yields the JS event loop. But our handlers need to await a JavaScript promise (the pglite query). If we blocked the synchronous Go call waiting on that promise, the event loop would never run, the promise would never settle, and we would deadlock.

LiftWasmServer solves this by handing back a Promise and running ServeHTTP on a goroutine:

executor := js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
resolve := promiseArgs[0]
go func() {
// ...ServeHTTP, then...
resolve.Invoke(string(out))
}()
return nil
})
return js.Global().Get("Promise").New(executor)

Returning the promise immediately hands control back to the event loop, which lets the nested pglite promises resolve and the blocked goroutine make progress.

The database bridge: pglite as a JS function

Section titled “The database bridge: pglite as a JS function”

On the JavaScript side, database-bridge.js boots a pglite instance (Postgres compiled to WASM) and exposes one function that the Go side calls:

browser/database-bridge.js
import { PGlite } from "./pglite/index.js";
const db = await PGlite.create("idb://my-pgdata");
window.queryDatabase = async function (query, args) {
const params = Array.isArray(args) ? args : [];
const res = await db.query(query, params);
return JSON.stringify({
rows: res.rows ?? [],
fields: res.fields ?? [],
affectedRows: res.affectedRows ?? 0,
error: null,
});
};

idb://my-pgdata means the database is persisted to IndexedDB — the user’s data survives a page reload, all client-side.

The Go WasmDatabase is the mirror image. It implements the Database interface by marshalling the query and args, invoking queryDatabase, and awaiting the returned promise:

// internal/WasmPostgres.go (build tag: wasm)
func (db *WasmDatabase) executeQuery(query string, args ...any) (*WasmQueryResult, error) {
promise := db.queryFunc.Invoke(query, js.ValueOf(args))
jsonBytes, err := awaitPromise(promise)
if err != nil {
return nil, err
}
var result WasmQueryResult
json.Unmarshal(jsonBytes, &result)
if result.Error != nil {
return nil, result.Error // Postgres-like error surfaced from JS
}
return &result, nil
}

It also implements Query, QueryRow, Exec and CopyFrom so that row.Scan(&id) and rows.Next() behave exactly like real pgx. One detail worth noting: pglite returns rows as JSON objects, and Go map iteration order is randomized, so WasmDatabase uses the field metadata to scan columns back in SELECT order rather than map order:

// scanMap preserves column order using the returned field descriptors,
// so Scan(&u.Id, &u.FirstName, ...) lands in the right destinations.
names := make([]string, 0, len(row))
if len(fields) > 0 {
for _, f := range fields {
names = append(names, f.Name)
}
}

In main.js, a thin localFetch wrapper turns the exposed Go function into a drop-in replacement for fetch — it even hands back a genuine Response object, so callers cannot tell it is not a network server:

async function localFetch(url, opts = {}) {
const raw = await window.handleWasmRequest(
opts.method || "GET",
url,
opts.body || "",
JSON.stringify(opts.headers || {}),
);
const { status, headers, body } = JSON.parse(raw);
const h = new Headers();
for (const [k, vs] of Object.entries(headers || {})) {
for (const v of vs) h.append(k, v);
}
return new Response(body, { status, headers: h });
}

And the generated client classes from emi js are used to parse the responses, exactly as they would be against a remote server:

import { SubstringActionRes } from "./gen/SubstringAction.js";
const res = await localFetch("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: "hello world", start: 0, end: 5 }),
});
const parsed = new SubstringActionRes(await res.text());

The CRUD UI does the same against /users:

const res = await window.localFetch("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: "Ada",
lastName: "Lovelace",
birthDate: "1815-12-10",
}),
});

POST /users lands in the Go net/http router, runs users.Create, executes the parameterized INSERT ... RETURNING id against pglite, and returns a JSON body that the generated CreateUserActionRes class parses — all without leaving the tab.

  • One implementation, two runtimes. The handler code, the SQL, and the generated DTOs are identical between the Gin server and the browser. The only thing that changes is which Database implementation is injected, and that is a one-line decision in main.
  • A real database, not a mock. pglite is genuine Postgres. The same INSERT ... RETURNING, the same SERIAL PRIMARY KEY, the same error semantics. You are not testing against an approximation.
  • Offline-first and zero-backend demos. Because the data persists to IndexedDB, you can ship a fully interactive app — server logic and all — as static files. Great for demos, playgrounds, local-first apps, and tests.
  • The transport is generated, the logic is yours. Emi generates the Gin binding, the net/http binding, and the JS client from one spec, so adding a new action means editing the YAML and re-running make def. The hand-written parts stay small and shared.
Terminal window
cd examples/in-browser-server
make def # generate Go handlers + JS clients from the spec
make wasm # compile the browser server to WebAssembly
# serve ./browser over http and open index.html

Open the page, create a user, refresh the list, reload the tab — the row is still there. You just ran a Postgres-backed HTTP server inside a browser, with the same code you would deploy to production.