Tauri + WKWebView: lessons from building a presentation app
by Faisca
Tauri + WKWebView: what we learned the hard way
StellarDeck is a markdown presentation app built with Tauri 2.0. It runs as a native macOS app with a WKWebView rendering the slides. Over two intense days of development, we hit every WKWebView gotcha there is. Here is what we learned so you don’t have to.
1. WKWebView caches everything, aggressively
The first surprise: changing HTML, CSS, or JS files and restarting cargo tauri dev does not reload them. WKWebView caches assets and ignores file timestamps.
The fix: a cache-buster parameter in tauri.conf.json:
"url": "viewer.html?file=test/deck.md&_cb=17"Every time we change frontend code, we increment _cb. It is annoying, manual, and works. We tried Cache-Control: no-store headers from our dev server — WKWebView ignores them.
Gotcha within the gotcha: the _cb only busts the HTML file cache. CSS and JS loaded via <link> and <script> tags are cached separately. When we refactored CSS mid-session, the old styles persisted even after a Tauri restart. We had to force-reload stylesheets from JavaScript:
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => { link.href = link.href.split('?')[0] + '?_cb=' + Date.now();});2. ES modules fail silently
If the dev server is down or returns 404 for a .js file, <script type="module"> produces no error. The module simply does not execute. No console error, no network error, nothing.
This is particularly cruel during development. You rename a file, forget to update an import, and the app loads with a blank screen and zero feedback.
How to debug: open the WebView console (not visible in the terminal) and try a dynamic import:
import('./js/state.js').catch(e => console.log(e));3. The open binary is not cargo tauri dev
Opening the compiled binary directly (open target/debug/stellardeck) sets the current working directory to / or ~. Our get_project_root function walks up from cwd to find viewer.html — this fails when cwd is root.
Always use cargo tauri dev for development. For production, the binary needs a fallback path resolution strategy.
4. The slide engine fights for keyboard control
At the time, we were using Reveal.js as the rendering engine. It has its own keyboard handler that captures arrow keys, space, escape, and many other keys. StellarDeck needs custom keyboard handling for grid navigation, tab switching, and fullscreen — so we had to win that fight.
Solution: disable the engine’s keyboard handling entirely and use a capture-phase listener:
Reveal.initialize({ keyboard: false, overview: false });
document.addEventListener('keydown', (e) => { // Our handler runs first, with stopImmediatePropagation if needed}, true); // capture phaseWe also disabled overview mode (overview: false) and built our own grid view, because the built-in overview is a single horizontal line, not a grid.
We later replaced Reveal.js with our own slide engine (StellarSlides, 380 lines of vanilla JS), and the same lesson held: whatever engine you use, own the keyboard handling on capture phase.
5. Fullscreen through Rust, not JavaScript
element.requestFullscreen() and element.webkitRequestFullscreen() are unreliable in WKWebView. Sometimes they work, sometimes they silently fail, sometimes they fullscreen the webview but not the native window.
Solution: use Tauri’s window API:
#[tauri::command]pub fn toggle_fullscreen(window: tauri::Window) -> Result<(), String> { let is_fs = window.is_fullscreen().map_err(|e| e.to_string())?; window.set_fullscreen(!is_fs).map_err(|e| e.to_string())?; Ok(())}This controls the native macOS window, not just the webview.
6. Tauri errors are strings, not Error objects
Rust Result<T, String> errors arrive in JavaScript as plain strings, not Error objects. This means err.message returns undefined:
// Wrong:catch (err) { showToast(err.message); } // undefined
// Right:catch (err) { showToast(err?.message || String(err)); }We hit this in every catch block before we learned the pattern.
7. The MCP bridge is a game-changer for debugging
We integrated tauri-plugin-mcp-bridge, which lets Claude Code connect directly to the running Tauri app. From the AI agent, we can:
- Take screenshots of the WebView
- Execute JavaScript in the app context
- Inspect DOM structure
- Read computed styles
This replaced the painful cycle of “change code → restart app → check visually → describe what you see.” Instead, the agent sees the app directly and iterates faster.
Setup: add the plugin to Cargo.toml (debug builds only) and register it:
#[cfg(debug_assertions)]{ builder = builder.plugin(tauri_plugin_mcp_bridge::init());}8. BroadcastChannel works in WKWebView
For our presenter mode (a second window showing speaker notes), we needed cross-window communication. The options:
window.postMessage— unreliable in Tauri, needs a window reference- Tauri IPC events (
emit/listen) — requires Rust involvement BroadcastChannel— browser API, same-origin, zero Rust code
We tried BroadcastChannel and it works in macOS WKWebView (Safari 15.4+). Both the main window and the presenter window share the same origin, so they can communicate directly:
// Main windowconst channel = new BroadcastChannel('stellardeck-presenter');Reveal.on('slidechanged', () => { channel.postMessage({ type: 'slide-update', indexh, notes, ... });});
// Presenter windowconst channel = new BroadcastChannel('stellardeck-presenter');channel.onmessage = (e) => applySlideUpdate(e.data);No Rust code, no IPC bridge, no postMessage handshake. It just works.
9. titleBarStyle: “Overlay” needs platform detection
Tauri’s titleBarStyle: "Overlay" makes the traffic lights (red/yellow/green) float over the web content. This looks great on macOS — but on Windows and Linux, there are no traffic lights, just a different window decoration.
We added platform detection in JavaScript:
if (IS_TAURI) { document.body.classList.add('tauri-app'); if (navigator.platform.startsWith('Mac')) { document.body.classList.add('tauri-overlay'); }}And in CSS, the 78px left padding only applies on macOS:
body.tauri-overlay #toolbar { padding-left: 78px; }The meta-lesson
Building a desktop app with web technology means you inherit both worlds’ problems. WKWebView is not Chrome. Tauri is not Electron. The gaps are small but sharp — and they only show up when you are trying to ship, never when you are reading the documentation.
Our approach: log everything in Rust (log::info!), test in the browser first (95% of the code is shared), and save the Tauri-specific debugging for the last mile. The MCP bridge made that last mile much shorter.