It's physics, not a bug: the IPC bandwidth wall in webview apps

A snapshot slideshow in our Tauri desktop app froze the UI whenever you sped it up. The Rust side was fast. The disk was fast. The images were already on the machine. So why did the window lock up the moment frames started flowing?
Because we were pushing full-resolution images across the boundary between JavaScript and native code, one base64 string per frame, through the command channel. At slideshow speed that channel saturated, and the JavaScript thread spent its time decoding strings instead of painting. It looked like a performance bug. It wasn't. It was the architecture doing exactly what the architecture does.
This post is about that wall: why it exists in every webview-to-native app (Tauri, Electron, and more), how to measure it, and how to design so your data flow never lands on the hot path. The fix is not a faster runtime. It's knowing where the boundary is and keeping big payloads off the one thread that paints your UI.
Key Takeaways
- Crossing the JS-to-native boundary always costs serialize + copy + deserialize; you can shrink each term but never make it zero.
- The freeze is whatever lands on the single JS main thread. In a measured 30fps slideshow, base64 images decoded in JS blocked that thread ~890ms over 2 seconds; the same images through a custom protocol blocked ~0ms.
- Returning raw
Vec<u8>over Tauri'sinvokeis a trap: it arrives in JS as a JSON number array, larger than base64.- The fix is keeping bytes off the JS thread: serve them through a custom URI-scheme protocol or a channel, not a command.
What actually crosses the boundary in a webview app?
A webview app is two programs sharing a window. The frontend is JavaScript running in a webview, with its own heap and a single main thread that handles layout, paint, and input. The backend is native code (Rust, in Tauri's case) in a separate process with a separate heap. They don't share memory, so they can't hand each other a pointer. Anything that moves between them has to be encoded on one side, copied across, and decoded on the other.
That copy channel is what people mean by "IPC" here: inter-process communication. In Tauri it's the invoke command interface plus an event system; in Electron it's ipcRenderer/ipcMain. The mental trap is treating a command call like a local function call. It isn't. It's a message sent to another process, and the message has to be serialized to cross.
For small, structured data (a config object, a list of IDs, a status string), the cost is invisible. For a 6MB image at slideshow cadence, it's the whole story.
The law: the serialize-and-copy tax
Every value that crosses the boundary pays a fixed sequence of costs: serialize on the sender, transport across the process boundary, copy into the receiver's heap, deserialize on the receiver. You can make each term cheaper. You cannot make any of them free, because two separate heaps fundamentally cannot share a pointer.
This is the "physics." It's not a Tauri limitation or an Electron limitation. It's a property of having two memory spaces and a channel between them. The base64 over IPC that froze our slideshow wasn't a defect in the framework; it was us asking the channel to carry something it was never meant to carry, as often as we could.
The interesting part isn't that the tax exists. It's where the bill arrives. And in a webview app, the bill arrives on the one thread you cannot afford to block.
Where the milliseconds go: it's the JS thread, not "base64"
The freeze is whatever lands on the single JavaScript main thread, and there are two separate costs, not one. First, the command channel hands the result back to JS, which has to materialize it: a multi-megabyte string or array gets parsed and built in the JS heap, on the main thread. Second, if you decode that payload yourself in JavaScript (the classic atob plus a byte-copy loop), the decode runs on the main thread too. Both block layout, paint, and input for as long as they take.
Here's the part that surprised me, and it's worth stating plainly because it kills a lazy conclusion. Base64 is not the villain. When I handed the exact same base64 string straight to an <img src="data:..."> and let the browser decode it natively, the JavaScript main-thread block was 0ms in our benchmark. The browser does that decode off the main thread. The slideshow only froze when we decoded in JavaScript with atob, which blocked the thread for hundreds of milliseconds per run.
So the enemy isn't a format. It's big payloads touching the JavaScript thread, whether that's the string arriving over the channel or a decode you wrote by hand. Base64 makes it worse (it inflates the bytes by about a third, four output characters for every three input bytes, per RFC 4648), but inflation is the smaller problem. The thread occupancy is what users feel.
Multiply one frame's cost by slideshow cadence, or by a grid trying to load 200 thumbnails at once, and the main thread never gets a turn to paint. That's the freeze.
It isn't Tauri's fault: one law, many stacks
The same wall shows up everywhere two heaps meet. Tauri's command channel serializes through a JSON-RPC-style protocol by default (Tauri docs). Electron's ipcRenderer uses the structured clone algorithm, which deep-copies ordinary objects (MDN); it isn't zero-copy. React Native's old "bridge" was an asynchronous, serialization-based boundary, and its replacement (JSI) exists precisely to remove that serialization step (React Native docs). Even a microservice talking JSON over HTTP is paying the same tax, one network hop further out.
What varies between stacks isn't whether the wall exists. It's how early you hit it and what escape hatches you get. Electron, for instance, can transfer an ArrayBuffer across a MessagePort as a genuine ownership move: the memory is handed over, not copied, in what MDN calls a "fast and efficient zero-copy operation" (MDN, Transferable objects). That's the one place "zero-copy" is literally true, and it's worth knowing it's only true for transferables, not for a structured-clone of a normal object and not for an HTTP-style protocol, both of which copy.
Tauri's equivalent escape hatches are a custom URI-scheme protocol and a channel. We'll get to both. The point of naming all these stacks is to stop you from filing a framework bug and start you redesigning the data flow.
Measuring it: a clone-and-run benchmark
I built a small public Tauri 2 app to measure this honestly, because asserting "base64 freezes the UI" without numbers is just folklore. It drives two real scenarios (a 30fps slideshow on a screenshot-sized image, and a 200-image thumbnail grid) through five transports, while measuring how long the JavaScript main thread is held.
One measurement note that matters: WebKit, which is the webview Tauri uses on macOS, does not implement the Long Tasks API, so you can't use it to detect main-thread stalls there. Instead the harness times the synchronous JavaScript work directly with performance.now() (a focus-independent number) and counts dropped frames with a requestAnimationFrame heartbeat (the felt freeze, which needs the window focused because browsers throttle timers and rAF in the background).
The five transports, in the rough order you would reach for them:
- base64 + JS decode (
invokereturns a base64 string, decoded in JS withatob) - base64 native (
invokereturns base64, handed straight to<img src="data:...">) - raw bytes over invoke (a command returning
Vec<u8>) - custom protocol (a
bench-img://URI scheme serving raw bytes off-thread) - channel (
ipc::Channelstreaming raw chunks)
Here is what the slideshow at 30fps measured on an Apple M2 Max:
| Transport | Main-thread block (ms) | Frame stall (ms) | Swaps completed (of 60) |
|---|---|---|---|
| base64 + JS decode | 891 | 1129 | 53 |
| base64 native | 0 | 251 | 60 |
| raw bytes over invoke | 48 | 1811 | 10 |
| custom protocol | 0 | 46 | 57 |
| channel | 50 | 1672 | 17 |
And the 200-image thumbnail grid:
| Transport | Main-thread block (ms) | Dropped frames |
|---|---|---|
| base64 + JS decode | 266 | 43 |
| base64 native | 0 | 0 |
| raw bytes over invoke | 39 | 3 |
| custom protocol | 0 | 0 |
| channel | 55 | 2 |
The custom protocol holds the main thread for roughly zero milliseconds and drops roughly zero frames, in both scenarios, because the bytes never enter JavaScript. The naive base64 path blocks the thread for the better part of a second and drops dozens of frames. That gap is the entire difference between a slideshow that plays and one that freezes.
The surprise: "raw bytes over invoke" is the worst path
The most counterintuitive result deserves its own callout. Returning raw Vec<u8> from a Tauri command looks like the obvious way to skip base64. It isn't. When a command returns bytes, they don't arrive in JavaScript as an ArrayBuffer; they arrive as a JSON array of numbers, like [82, 73, 70, 70, ...]. That's larger than base64 and still has to be parsed on the main thread.
The bytes-on-the-wire numbers for the large image make it concrete: base64 was 8.4MB, the genuinely raw bytes were 6.3MB, and the "raw" Vec<u8> over invoke ballooned to 22.5MB as a number array. In the slideshow it completed only 10 of 60 swaps, the fewest of any transport.
Don't be fooled by its small main-thread-block bar in the chart above. That metric counts only the synchronous decode you write by hand, and this path does almost none. The expensive work, parsing 22.5MB of JSON into a JavaScript array, happens inside invoke itself as the promise resolves, still on the main thread. That's why raw-over-invoke records the highest frame stall of any path (1811ms) and finishes the fewest swaps, even though its block number looks tiny. The lesson: the command channel is JSON-shaped, full stop. If you want raw bytes in the webview, you do not get them from a command.
Designing around the boundary
The fix isn't a trick; it's a handful of habits, each of which keeps big payloads off the JavaScript thread.
Don't push big data through the command channel. Serve it. This is the change that fixed our slideshow. Register a custom asynchronous URI-scheme protocol, serve the raw image bytes from it on a spawned thread, and let the webview fetch and decode them natively through an ordinary <img> tag. The bytes never touch JavaScript, the decode happens off the main thread, and the webview caches the result for you.
The Rust side is a small handler. It reads the bytes, sets a real content type and cache header, and responds from a worker thread so disk I/O and any decryption stay off the webview's IPC thread:
// Registered with register_asynchronous_uri_scheme_protocol.
// Runs on a spawned thread; serves raw bytes, not base64.
fn handle(request: Request<Vec<u8>>) -> Response<Vec<u8>> {
let id = request.uri().path().trim_matches('/');
let (bytes, mime) = match load_image(id) {
Some(image) => image,
None => return not_found(),
};
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime)
.header("Cache-Control", "private, max-age=31536000, immutable")
.body(bytes)
.unwrap()
}
The frontend gets simpler, not more complex. The painful version held a base64 string in JavaScript and decoded it by hand. The fixed version is one attribute:
<!-- before: a base64 string crosses IPC, then atob() blocks the main thread -->
<!-- after: bytes are fetched and decoded natively, off the main thread -->
<img src="bench-img://localhost/42" />
Don't expect raw bytes back from a command. As the benchmark showed, Vec<u8> over invoke comes back as a JSON number array. For bytes, reach for a custom protocol or a channel, never a command return value.
Stream with a channel instead of one fat response. Tauri's ipc::Channel delivers ordered chunks and is built for throughput, where the event system is not. It still does per-chunk work on the JavaScript side, so it's not as clean as a protocol for images, but it beats a single giant payload for progressive or very large data.
Keep heavy state on the backend and send the slice the UI renders. If the frontend only shows a page of results, send that page (IDs, summaries, thumURLs), not the whole dataset. The cheapest boundary crossing is the one that carries the least.
Know when base64 is actually fine. This matters, because "never use base64" is the wrong takeaway. A 32x32 placeholder, a single export, an icon: tiny, one-shot, infrequent payloads over a data URL are completely fine. We still base64-encode our low-quality image placeholders. The rule is size times frequency, not "base64 bad."
The mental model: four questions before every crossing
For every value that crosses the JS-to-native boundary, ask four questions before you write the call. Where is the boundary? What crosses it? How big is it? How often?
Those four questions would have caught our freeze at code-review time. A full-resolution image (big) crossing the command channel (the JSON boundary) every frame (often) is a saturated channel waiting to happen. The same image served once through a protocol and cached is a non-event. Same data, same app, completely different felt performance, and the only thing that changed was the answer to "how big, how often, across which boundary."
This is also a clean review heuristic for a team. Any time you see a large payload going through invoke (or ipcRenderer) on a hot path, that's the smell. The size-times-frequency product is your budget.
It's physics, so design with it
The IPC wall isn't a bug to be patched or a framework to be replaced. It's the cost of having two heaps and a channel between them, and that cost is real in Tauri, Electron, React Native, and every microservice that speaks JSON. You don't fight physics. You lay out your data flow so the boundary is never on a hot path: serve big bytes through a protocol, stream with a channel, send the slice the UI renders, and keep the one thread that paints your UI free to do its job.
Our slideshow went from a stutter to smooth with no faster hardware and no framework change. We just stopped asking the command channel to carry what it was never built to carry.
The benchmark is open and runnable: clone it, run the slideshow and the grid yourself, and watch the main-thread numbers. The contrast is hard to argue with once you've seen your own frames drop.
Frequently asked questions
No. Tauri's `invoke` command channel is fine for the small, structured messages it's built for. It feels slow only when you push large or high-frequency payloads through it, like full-resolution base64 images at slideshow cadence, because the cost scales with size times frequency. Use it for control messages, not for moving megabytes.
The freeze is the work on the single JavaScript main thread: receiving the multi-megabyte string from the command channel, and, if you decode it yourself with `atob`, the decode. In a measured 30fps slideshow, base64 plus a JS decode blocked the main thread about 890ms over two seconds. Handing the same base64 to a native `<img src="data:...">` blocked 0ms, so the freeze is the JavaScript-side decode, not base64 itself.
Don't return it from a command. Register a custom asynchronous URI-scheme protocol that serves the raw bytes with a real content type, run the handler on a spawned thread, and consume it as an ordinary `<img src="your-scheme://id">`. The webview fetches and decodes the bytes natively, off the main thread, and caches them, with no JavaScript hot-path work.
Not reliably. In this benchmark, returning `Vec<u8>` arrived in the frontend as a JSON number array, not an `ArrayBuffer`: 22.5MB for a 6.3MB image, larger than base64, and still parsed on the JavaScript thread. It was the slowest path measured. For true raw bytes, use a custom protocol or a channel, not a command.
Both, and neither specifically. It's a property of separate-heap architectures. Electron hits the same wall but offers transferable `ArrayBuffer` over `MessagePort` for a genuine near-zero-copy transfer; Tauri's equivalent is the custom protocol or a channel. What differs between stacks is how early you hit the wall, not whether it exists.
When the payload is tiny, one-shot, and infrequent: a 32x32 low-quality placeholder, a single export, an icon. The rule is size times frequency. A small data URL sent once is a non-event; a full-resolution image sent every frame saturates the channel. The format isn't the problem; the volume on the JavaScript thread is.