The Native Messaging Wire Format
How native messaging frames JSON over stdin and stdout: the four-byte native-order length prefix, UTF-8 byte counts, size limits, partial reads, and the stdout mistakes that break the stream.
Table of Contents
The extension connects, the host starts, and then nothing works: the port disconnects with “Error when communicating with the native messaging host”, or the first message arrives and the second is garbage, or everything is fine until a user with a non-ASCII name sends a message and the connection dies. Every one of these is a framing bug. Native messaging is a byte protocol with exactly one rule, and nearly every host gets that rule slightly wrong on the first attempt. This guide belongs to native messaging and host integration.
The one rule, and why it is easy to break
Each message, in either direction, is a 32-bit unsigned integer giving the payload length in bytes, written in the machine’s native byte order, followed immediately by that many bytes of UTF-8 encoded JSON. There is no delimiter, no newline, no header beyond those four bytes, and no recovery: if the reader ever loses its place, every subsequent read interprets payload bytes as a length and the stream is unrecoverable. The common ways to lose your place are measuring the payload in characters instead of bytes, writing a log line to stdout, assuming one read returns one whole message, and — on Windows — letting the C runtime translate \n into \r\n on a stream opened in text mode.
Step-by-step: framing that cannot drift
1. Count bytes, not characters
1// WRONG — string length counts UTF-16 code units
2const bad = JSON.stringify({ user: "Zoë 👋" });
3header.writeUInt32LE(bad.length, 0); // 16, but the UTF-8 payload is 20 bytes
4
5// RIGHT — encode first, then measure the buffer
6const payload = Buffer.from(JSON.stringify({ user: "Zoë 👋" }), "utf8");
7header.writeUInt32LE(payload.length, 0); // 20
Execution context: the host process (Node here). ë is two bytes in UTF-8 and the emoji is four, so a character count undercounts and the browser reads the tail of your JSON as the next length prefix. In Python, len(json.dumps(msg).encode("utf-8")) is the right measure; in Go, len([]byte(s)). The browser side never has this problem because the browser does the framing for you.
2. Write the length in native byte order
1# Python host
2import struct, sys, json
3
4def send(msg):
5 data = json.dumps(msg, separators=(",", ":")).encode("utf-8")
6 sys.stdout.buffer.write(struct.pack("=I", len(data))) # "=" = native order, standard size
7 sys.stdout.buffer.write(data)
8 sys.stdout.buffer.flush()
Execution context: the host process. "=I" gives native byte order with a standard four-byte size; "I" alone can pick a platform-dependent size and alignment. Every desktop platform browsers run on is little-endian today, so "<I" is equivalent in practice, but native order is what the specification says. Writing to sys.stdout instead of sys.stdout.buffer sends text through an encoder that may mangle bytes. Always flush — buffered output that never reaches the browser looks identical to a hung host.
3. Read exactly, across partial reads
A pipe delivers bytes in whatever chunks the OS chooses. One read may return half a length prefix, or three messages and part of a fourth.
1def read_exact(n):
2 buf = b""
3 while len(buf) < n:
4 chunk = sys.stdin.buffer.read(n - len(buf))
5 if not chunk:
6 return None # browser closed stdin: exit cleanly
7 buf += chunk
8 return buf
9
10def receive():
11 header = read_exact(4)
12 if header is None:
13 return None
14 (length,) = struct.unpack("=I", header)
15 body = read_exact(length)
16 return json.loads(body.decode("utf-8")) if body is not None else None
Execution context: the host process. EOF on stdin is the browser’s way of saying the port was disconnected or the one-shot reply was received; the host should exit promptly when it sees it. Hosts that ignore EOF linger as orphan processes, and a later connectNative starts another one beside them.
4. Keep stdout for frames only
1// Node host — redirect every log to stderr before anything else runs
2console.log = (...args) => process.stderr.write(args.join(" ") + "\n");
3console.info = console.log;
4console.debug = console.log;
Execution context: the top of the host’s entry file. Libraries print to stdout more often than you would expect — deprecation warnings, progress bars, a stray print in a dependency. Rebinding the console catches JavaScript writes; for child processes the host spawns, pass stdio: ["ignore", "pipe", "inherit"] so their output never reaches the browser. Chrome writes the host’s stderr to its own log when started with --enable-logging.
5. Respect the size limits
1const MAX_TO_BROWSER = 1024 * 1024; // 1 MB from host to browser
2
3function send(msg) {
4 const payload = Buffer.from(JSON.stringify(msg), "utf8");
5 if (payload.length > MAX_TO_BROWSER) {
6 return sendChunked(msg.id, payload); // or write to a temp file and send its path
7 }
8 const header = Buffer.alloc(4);
9 header.writeUInt32LE(payload.length, 0);
10 process.stdout.write(Buffer.concat([header, payload]));
11}
Execution context: the host process. Chrome rejects host-to-browser messages over 1 MB and disconnects; browser-to-host messages may be up to 64 MiB. Firefox applies similar limits. For large data, chunk with a sequence number and reassemble in the service worker, or write to a file in a location the extension can read through a download or a file:// URL the user opens.
6. Use binary-safe stdio on Windows
1// C/C++ host on Windows — before any read or write
2#include <fcntl.h>
3#include <io.h>
4_setmode(_fileno(stdin), _O_BINARY);
5_setmode(_fileno(stdout), _O_BINARY);
Execution context: the host process on Windows. In text mode the C runtime converts 0x0A to 0x0D 0x0A on write and the reverse on read. A length prefix of 10 — 0A 00 00 00 — becomes five bytes and every frame after it is misaligned. Node, Python’s .buffer streams and Go’s os.Stdin are already binary; C, C++ and some older runtimes are not.
Cross-browser variation
- Chrome / Edge: native byte order, 1 MB host-to-browser limit, 64 MiB browser-to-host. Passes the caller origin as
argv[1]and, on Windows,--parent-window=<hwnd>as the next argument. - Firefox: the same framing and limits. Passes the host manifest path and the add-on id as arguments instead of an origin — hosts that parse
argv[1]as an origin must handle both. - Safari: no stdio framing at all. Messages arrive in the containing app’s
SafariWebExtensionHandleras anNSExtensionContextrequest with a dictionary payload; the length-prefix rules do not apply.
Verification
Test the host without a browser. Pipe a framed message in and inspect the raw bytes that come out.
1node -e '
2const p = Buffer.from(JSON.stringify({type:"version"}),"utf8");
3const h = Buffer.alloc(4); h.writeUInt32LE(p.length,0);
4process.stdout.write(Buffer.concat([h,p]));' \
5| ./vault-host | xxd | head
6# 00000000: 1100 0000 7b22 7665 7273 696f 6e22 3a22 ....{"version":"
Execution context: a terminal. The first four bytes must equal the payload length in little-endian, and byte five must be {. Anything else — text before the brace, a length that does not match — is the bug. Repeat with a payload containing emoji and with two messages concatenated in one write.
FAQ
Can I use newline-delimited JSON instead?
No. The browser always reads a four-byte length first. A host that writes newline-delimited JSON has its first bytes {"ty interpreted as a length of about two billion, and Chrome disconnects.
Is there a maximum number of messages?
No limit on count, only on the size of each message. A long-lived port can carry millions of messages, provided each is framed correctly and the host keeps up with reading stdin.
Does the JSON have to be an object?
Any JSON value works on the wire, but the extension APIs deliver whatever you send as the message, and objects with a type field make dispatching and versioning far easier on both sides.
Related
- Writing a native messaging host in Node — a complete host built on these rules.
- Debugging “native host has exited” — where framing bugs usually surface.
- Passing structured data and transferables — what JSON loses compared with in-browser messaging.
- Native messaging and host integration — the parent topic.