Back to blog

06 / writing

When the web isn't enough: the Wails bridge protocol

Why some software has to leave the browser — and how a thin bridge lets you keep web-quality UIs while running real native code underneath.

The web won — except where it didn’t

For most software, the browser is the right answer. A web app installs nothing, updates itself, runs on every OS, and lets you build the UI with the richest, best-tooled front-end ecosystem on earth. If you can ship a web app, you usually should.

But the browser is a sandbox, and that sandbox has walls. They exist for good reasons — a random web page should not be able to read your microphone’s raw loopback stream, open arbitrary files, talk to a USB device, or run a tight numerical loop that pins a CPU core. The same restrictions that make the web safe to browse make it the wrong tool for a whole class of programs.

When you hit one of those walls, you don’t actually want to throw away the web UI. The HTML/CSS/JS rendering stack is great. What you want is to keep the dashboard and bolt it onto an engine that runs outside the sandbox — a real native process, in a language that’s good at the thing the browser won’t let you do.

That seam between “web UI” and “native engine” is the bridge. This post is about what a bridge protocol actually is, and the decision rule for when you need one.


When a web program isn’t the right tool

Four signals, any one of which should make you reach past the browser.

1. You need hardware the sandbox won’t hand over

Browsers expose a curated, permission-gated slice of the machine: a camera, a microphone input, maybe a gamepad or a single USB device through WebUSB if the user clicks through a prompt. What they deliberately don’t give you is the messy, powerful stuff:

  • System / loopback audio — “record what I’m hearing,” not just “record my mic.” The Web Audio API can capture a mic; it cannot reliably tap the OS audio output. A meeting recorder, a transcription tool, an audio router — all need the OS’s native audio API.
  • Arbitrary filesystem access — watching a directory, memory-mapping a large file, managing a local database on disk.
  • Serial ports, Bluetooth stacks, printers, scanners, scientific instruments — peripherals whose drivers live in C and speak protocols the browser never modeled.
  • Global hotkeys, tray icons, window management, auto-start — OS integration that a tab simply isn’t allowed to do.

If your feature list contains a peripheral or an OS capability the sandbox gates off, that’s the clearest signal of all. No amount of clever JavaScript gets you past a wall that’s there on purpose.

2. You need raw compute, in a language built for it

JavaScript in a browser is fast — until it isn’t. For UI logic and I/O it’s perfectly fine. But for sustained number-crunching it pays a real tax: a single-threaded event loop, garbage-collection pauses, JIT warm-up, and no true shared-memory parallelism without jumping through Web Worker / WASM hoops.

When the core of your program is computation — signal processing, simulation, encryption, parsing gigabytes, real-time inference, anything that wants every core of the machine — you want a language designed for that: Go, Rust, C, C++, Zig. Compiled, multi-threaded, predictable memory, direct access to SIMD and native libraries. You move the heavy loop into that language and let the web layer stay a thin presentation skin over it.

The tell: your performance ceiling is a CPU problem, not a network problem. If making the work faster means “use more cores / less GC / tighter memory,” the browser is the wrong runtime for that work.

3. It must work without a server — privacy, latency, or offline

A web app, almost by definition, phones home. For some software that’s unacceptable:

  • Privacy — the data must never leave the machine. Medical notes, legal material, raw meeting audio, anything you’d rather not stream to someone else’s cloud. Native code can do the sensitive work locally and only send what the user explicitly chooses to.
  • Latency — a round trip to a server is tens to hundreds of milliseconds you don’t have if you’re reacting to live input in real time.
  • Offline — it has to keep working on a plane, in a hospital basement, behind a corporate firewall. No connection, no problem.

If “send it to a server” is a non-starter for any of those reasons, the logic has to live on the machine — which means a local program, not a page.

4. It must be one thing the user can just run

There’s a human factor too. A web stack often means “install Node, run two servers, set some environment variables, keep a terminal open.” For a developer that’s Tuesday. For everyone else it’s a wall.

A native app can ship as one executable: download a file, double-click it, it works. No runtime to install, no ports to remember, no localhost to keep alive. When your user is not a sysadmin — and especially when they’re using the tool under pressure — “one file that runs” is a feature in itself.


So you’ve decided to go native. Now what?

The naive native path is to also build the UI natively — Qt, GTK, Cocoa, WinUI. Powerful, but you give up the web’s design velocity and you rebuild per platform.

The modern answer is a hybrid: render the UI with web technology inside a native window, and run the logic as a compiled native binary. Electron popularized this, but it pays for it by bundling an entire Chromium + Node runtime — hundreds of megabytes — into every app.

The leaner approach uses the operating system’s own webview (WebKit on macOS, WebView2 on Windows, WebKitGTK on Linux) for rendering, and a single compiled binary for the logic. No bundled browser, no bundled Node. This is the model behind frameworks like Wails (Go) and Tauri (Rust). Small, fast, native — with a web UI on top.

And the moment you split the program into “web UI” and “native engine,” you need a way for the two halves to talk. That communication layer — its mechanism, its conventions, its rules — is the bridge protocol.


What a bridge protocol actually is

A bridge is not a network API. There’s no socket, no port, no HTTP between the two halves — they’re in the same process, on opposite sides of the webview boundary. The bridge is the set of rules for ferrying calls and data across that boundary. Every hybrid framework has one, and they all solve the same two problems:

Direction A — UI calls into native code (request/response)

You write a function in the native language. The framework reflects over it and generates a matching JavaScript stub. The UI calls the stub like any async function; the framework marshals the arguments (usually to JSON), carries them across the boundary, runs the real function on the native side, and resolves the JS promise with the result.

Native:  func Login(email, password string) (string, error)
                        │   (framework reflects + generates a binding)
                        ▼
JS:      App.Login(email, password)   // returns Promise<string>

This is the pull direction: the UI asks, the engine answers. Note how cleanly a language that returns (value, error) maps onto JavaScript’s resolve / reject — the bridge is largely a translation layer between two languages’ calling conventions. The UI never thinks about JSON or the boundary; it just “calls a function.”

Direction B — Native code pushes to the UI (events)

Request/response is fine for “fetch this data.” But native engines often produce streams — an audio level meter ticking 30 times a second, progress on a long computation, a log tail, a sensor reading. For those, the engine pushes rather than waiting to be asked.

Every bridge framework provides an event bus for this: the native side emits a named event with a payload; the UI subscribes to that name with a callback and unsubscribes when the component goes away.

Native:  emit("level:mic", amplitude)   ──▶   UI:  on("level:mic", updateMeter)
Native:  emit("progress", 0.42)         ──▶   UI:  on("progress", setProgressBar)

This is the push direction: the engine speaks, the UI listens.

The whole protocol in one rule

Pull for commands and queries; push for streams.

That’s it. Generated function bindings in one direction, a named event bus in the other. Two mechanisms, one boundary. Once you internalize that split, every hybrid-desktop framework looks the same under the hood — the names change (Bind vs. invoke, EventsEmit vs. emit), but the shape is identical.

  UI  (web, in the OS webview)                  ENGINE  (compiled native binary)
  ─────────────────────────────                 ─────────────────────────────────
  call("StartRecording")  ───────────────▶      func StartRecording()
        (promise resolves) ◀─────────────        return ok / error

  on("level:mic", setMeter) ◀────────────        emit("level:mic", db)
  on("transcript", append)  ◀────────────        emit("transcript", segment)

A design pattern worth stealing: the single steering wheel

A subtle but important convention shows up in well-built bridges. You don’t expose dozens of unrelated native objects to the UI. You expose one — a single facade struct whose methods are the app’s entire API surface. The framework reflects over that one object and generates the whole binding set from it.

That one object is the steering wheel. The UI turns it; behind it, the facade delegates to focused internal modules (an audio module, a transcription module, a storage module) that the UI never sees directly. The payoff:

  • One clean, named API — the UI thinks in verbs (StartRecording, Login, ListFiles), not in the messy plumbing behind each one.
  • A single audit point — everything crossing the boundary goes through one surface, so it’s easy to reason about what the UI can and can’t trigger.
  • Freedom to refactor — you can rewrite the engine internals completely as long as the facade’s method signatures hold.

It’s the Facade pattern, applied to a process boundary. The bridge gives you the transport; the single-facade convention gives you a clean contract to put on top of it.


A worked example: a meeting / interview recorder

To make all of this concrete, picture a tool that records a live conversation, transcribes it on the fly, and surfaces help in the moment — the kind of thing this codebase happens to be. Run the four signals against it:

  1. Hardware? Yes — it has to capture system audio (the other side of the call) plus the mic. The browser can’t reliably do loopback capture. → native audio API required.
  2. Compute? Real-time audio: voice-activity detection chopping the stream on silence, PCM buffering, streaming chunks to a transcription engine — all sustained, low-latency work that wants a compiled, concurrent language.
  3. Server-free? Live meeting audio is sensitive; users want the option to transcribe locally and keep it offline. → logic on the machine.
  4. Just run it? The user is mid-interview, not configuring a dev environment. → one double-clickable binary.

Four for four. This is a textbook case for going native — and for the bridge. The web UI shows the live transcript, the level meters, the controls. The native engine opens two audio streams, runs VAD on real threads, talks to a transcription backend, and pushes a transcript event for each finished segment and a level:mic event many times a second. The UI pulls with StartRecording / StopRecording. Pull for commands, push for streams — the same rule, doing real work.

Contrast that with, say, a blog CMS or a project tracker: no special hardware, no heavy compute, server-backed by design, and the browser’s “nothing to install” is the feature. That’s a web app, full stop. The bridge is for the other category — and now you can tell which category you’re in.


The decision, in one table

If your program……the web is…because
Needs loopback audio, serial/USB, filesystem watching, OS hooksnot enoughthe sandbox gates those off on purpose
Lives or dies on CPU-bound computenot enoughyou want compiled, multi-threaded, GC-light code
Must run offline / fully local / privacy-firstnot enougha page assumes a server; native logic doesn’t
Must ship as one double-clickable filenot enough“no install” beats “set up a runtime”
Is CRUD over a network, on every device, install-freeexactly rightthe sandbox costs you nothing here

When you land in one of the top four rows, you reach for a native local app — and the bridge is what lets you do that without giving up the web UI you’d have built anyway. You write the hard parts in a language that’s good at hard parts, you write the interface in the language that’s good at interfaces, and a thin two-direction protocol — generated calls one way, an event bus the other — stitches them into a single program the user just double-clicks.

That’s the whole idea. The web didn’t lose; it just has edges. The bridge is how you build right up to them.


Frameworks worth a look if you want to try this: Wails (Go + webview), Tauri (Rust + webview). Both implement exactly the two-direction protocol described here.


Further reading


About the author

Ivens Signorini is a Senior Backend Engineer focused on distributed systems, AI infrastructure, and high-performance APIs. He works primarily in Go and TypeScript, building systems that run at scale. His technical interests include protocol design, concurrency patterns, and the architecture of AI-native applications. He writes at signorini.cloud.

Tweaks

Accent
Theme
Headline font