Running the same server in the browser (Postgres in WASM)
Live Demo
Section titled “Live Demo”Before you read further, you can see it here working:
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.
The idea in one sentence
Section titled “The idea in one sentence”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.
What is shared and what is swapped
Section titled “What is shared and what is swapped”The whole design rests on a single small interface. The handlers never import
pgx directly or hold a concrete connection — they hold a Database:
//// 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 browserfunc (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 module spec
Section titled “The module spec”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.goemi gogenerates the typed Go handlers (defs/) — both a Gin binding and a plainnet/httpbinding for every action.emi jsgenerates the matching typed client classes (browser/gen/).make wasmcompiles the browser entrypoint to a.wasmfile.make servercompiles 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.
Two entrypoints, selected by build tags
Section titled “Two entrypoints, selected by build tags”The server entrypoint is unremarkable — it is plain Gin:
//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:
//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.
Lifting the router into the browser
Section titled “Lifting the router into the browser”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 ResponseWriterres := 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.
Why it returns a Promise
Section titled “Why it returns a Promise”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:
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) }}Calling it from the front end
Section titled “Calling it from the front end”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.
Why this matters
Section titled “Why this matters”- 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
Databaseimplementation is injected, and that is a one-line decision inmain. - A real database, not a mock. pglite is genuine Postgres. The same
INSERT ... RETURNING, the sameSERIAL 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/httpbinding, and the JS client from one spec, so adding a new action means editing the YAML and re-runningmake def. The hand-written parts stay small and shared.
Try it
Section titled “Try it”cd examples/in-browser-servermake def # generate Go handlers + JS clients from the specmake wasm # compile the browser server to WebAssembly# serve ./browser over http and open index.htmlOpen 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.