Four ways to connect a browser extension to a desktop app. Three have obvious trade-offs. The fourth requires writing a native binary — and that’s the one I picked.
Here’s why I chose Native Messaging with Unix sockets, and when you should (or shouldn’t) do the same.
Four Approaches, One Winner
When I built Think, a personal AI assistant with a browser extension that talks to an Electron app + FastAPI backend, I evaluated four approaches:
| Approach | Latency | Push Events | Complexity | Dependencies |
|---|---|---|---|---|
| HTTP polling from extension | High (~100ms+) | No (fake it with polling) | Low | None |
| Local WebSocket server | Medium (~5ms) | Yes | Medium | WS library |
| Native Messaging + HTTP | Medium (~5ms) | No | Medium | HTTP in host |
| Native Messaging + Unix sockets | Low (~0.1ms) | Yes | Higher | None |
I needed push notifications (app → extension) and low latency for a chatty protocol. That ruled out HTTP polling and Native Messaging + HTTP. WebSockets would work, but Native Messaging with Unix sockets gave me both requirements with zero runtime dependencies.
The Architecture
Extension <—> Browser <—> Native Host (C stub)
|
Unix Socket (length-prefixed)
|
FastAPI
|
Electron
Three layers, each with a purpose:
Native Host: A tiny binary that the browser spawns. On macOS/Linux, it’s ~200 lines of C with no dependencies. It connects to a Unix socket and forwards bytes in both directions.
Unix Socket: Persistent connection between the stub and FastAPI. Uses the same length-prefixed protocol as Native Messaging itself (4 bytes little-endian length, then JSON), so the stub is pure byte forwarding.
FastAPI + Electron: The actual application logic. FastAPI handles the socket server and routes requests. Electron connects via WebSocket for the UI layer.
One gotcha that cost me an afternoon: the socket must exist before the stub tries to connect. If FastAPI isn’t running yet, the stub dies silently and the browser just… stops sending messages. No error, no logs, nothing.
Why Not Just HTTP?
If the native host made HTTP requests to FastAPI instead:
| HTTP | Unix Socket | |
|---|---|---|
| Latency | ~1-5ms per request | ~0.1ms |
| Push events | No (need polling) | Yes |
| Connection | New per request | Persistent |
| Dependencies | None | None |
The persistent connection is the key win. FastAPI can push events to the extension without the extension asking — “your download finished,” “new data available,” “user clicked something in the app.”
The Trick That Makes It Simple
The native host doesn’t parse anything. Since both Native Messaging and our socket use the same length-prefixed protocol, the stub just moves bytes:
/* Core loop: stdin -> socket -> stdout */
while (1) {
fread(&msg_len, 4, 1, stdin); // Length from browser
fread(buf, 1, msg_len, stdin); // Message from browser
send(sock, &msg_len, 4, 0); // Forward to socket
send(sock, buf, msg_len, 0);
recv(sock, &msg_len, 4, 0); // Response from socket
recv(sock, buf, msg_len, 0);
fwrite(&msg_len, 4, 1, stdout); // Forward to browser
fwrite(buf, 1, msg_len, stdout);
fflush(stdout);
}
No JSON parsing, no HTTP overhead, no runtime dependencies. The complexity lives in FastAPI where it belongs.
This took me embarrassingly long to figure out. My first version had the stub parsing JSON, validating messages, handling errors — 600 lines of C that broke constantly. Then I realized: if both sides speak the same protocol, the middle doesn’t need to understand it.
The Windows Tax
macOS/Linux: Unix sockets + C stub. No runtime dependencies, compiles to a tiny binary.
Windows: Named Pipes + Python stub. Windows doesn’t have Unix sockets, and the Named Pipe API requires Win32 calls — easier to use Python with pywin32 than wrestle with C. (Yes, I tried the C route first. Two days of CreateNamedPipe debugging later, I admitted defeat.)
Both platforms use the same length-prefixed protocol, so the FastAPI side stays identical.
When This Is (and Isn’t) Worth It
This architecture makes sense when you need persistent, bidirectional communication — push notifications, chatty protocols, sub-millisecond latency. The trade-off is complexity: native binaries, socket servers, manifest registration across platforms. Expect gotchas — I’ve mentioned the startup ordering and Windows quirks, but there are more. Manifest registration differs across browsers. Chrome and Firefox have different JSON schemas. Debugging is brutal because there’s no console, just silent failures.
If your extension just needs occasional request/response, skip this and use HTTP through the native host. But once you need that persistent channel, sockets are the way.
Full implementation: Think on GitHub