Skip to content

Memory: WKWebView JS heap grows to 1.5G after 7 days (MALLOC_SMALL fragmentation) #41

Description

@kaito-harry

Environment

  • markon: 0.14.4 (Tauri v2, macOS)
  • macOS: 26.5.1 (25F80), Apple Silicon (arm64)
  • Uptime: 7 days (launched 2026-07-01, observed 2026-07-08)
  • Usage: tray-resident, settings window hidden; documents opened via HTTP server in system browser

Symptom

markon-gui process physical footprint reaches 1.5G after 7 days of uptime, even though the settings window is hidden and the HTTP server (document viewer) runs in the system browser — not in the Tauri webview.

After restart: 83MB (fresh process).

# After 7 days:
Physical footprint:         1.5G
Physical footprint (peak):  1.5G

# After restart:
Physical footprint:         83.1M
Physical footprint (peak):  116.8M

vmmap Output (after 7 days)

Process:         markon-gui [11022]
Path:            /Applications/Markon.app/Contents/MacOS/markon-gui
Launch Time:     2026-07-01 10:35:07.440 +0800
OS Version:      macOS 26.5.1 (25F80)

Physical footprint:         1.5G
Physical footprint (peak):  1.5G
Idle exit:                  untracked

                                VIRTUAL RESIDENT    DIRTY  SWAPPED VOLATILE   NONVOL    EMPTY   REGION
REGION TYPE                        SIZE     SIZE     SIZE     SIZE     SIZE     SIZE     SIZE    COUNT (non-coalesced)
JS VM Gigacage (reserved)          4.0G       0K       0K       0K       0K       0K       0K        1
MALLOC_SMALL                       1.4G     4.6M     4.6M     1.4G       0K       0K       0K       441
MALLOC_LARGE                       54.9M        0K       0K    47.2M       0K       0K       0K        12
MALLOC_SMALL (empty)               85.2M      16K      16K     7.4M       0K       0K       0K        34
MALLOC_TINY                        4096K     208K     208K     112K       0K       0K       0K         1
IOSurface                          94.8M        0K       0K       0K       0K       0K    67.6M        49
WebKit Malloc                      64.0M      48K      48K     2.3M       0K       0K       0K         3
WebKit Malloc metadata            128.0M     144K     144K     112K       0K       0K       0K         2
Stack                             154.3M     144K     144K     2.0M       0K       0K       0K        77
__TEXT                             1.2G   176.2M        0K       0K       0K       0K       0K      1056
__DATA                            34.3M     3.0M     360K     846K       0K       0K       0K       977
__DATA_CONST                      37.4M    10.9M      80K     3.0M       0K       0K       0K      1033
__OBJC_RO                          79.1M    23.6M        0K       0K       0K       0K       0K         1

TOTAL                              8.6G   220.5M     8.8M     1.5G       0K     544K    67.7M     7023
TOTAL, minus reserved VM space     4.6G   220.5M     8.8M     1.5G       0K     544K    67.7M     7023

Key observations:

  • 1.4G in MALLOC_SMALL, the JavaScriptCore heap region
  • 1.4G swapped out — only ~5M resident, so the JS heap is cold and paged to disk
  • 441 MALLOC_SMALL regions → extreme heap fragmentation
  • JS VM Gigacage 4.0G reserved → JavaScriptCore expanded to its max heap
  • 94.8M IOSurface → WebKit compositing layers still held while window hidden

Code Audit (v0.14.4 → v0.15.6 diff)

I compared the full source tree between the two versions and reviewed these potential leak sources in the Tauri webview (settings panel):

① NIC poll loop — crates/gui/src/main.rs:580 (unchanged between versions)

loop {
    sleep(Duration::from_secs(3)).await;
    let cur = markon_core::net::available_bind_hosts();
    if cur == prev { continue; }
    prev = cur.clone();
    let _ = app_for_watcher.emit("bind-hosts-changed", payload);
}

3-second polling loop emits events to the webview. Not the primary leak (NICs rarely change), but event payloads pass through Tauri IPC into JS context on each emission.

② Settings SPA — crates/gui/ui/index.html (3292 lines)

  • No setInterval calls (only short-lived setTimeout: 100ms–6s)
  • No WebSocket, EventSource, or fetch polling
  • 3 Tauri event listeners: bind-hosts-changed, server-status-changed, menu:new-workspace
  • No Service Worker, IndexedDB, or localStorage accumulation
  • No explicit JS leak found in the settings page code

③ No memory-related fix in v0.15.6

The 100 commits between v0.14.4 and v0.15.6 are focused on: git diff/compare views, workspace collaboration features, TypeScript frontend migration. None mention memory, leak, performance optimization, GC, cleanup, or webview lifecycle.

The NIC poll code, event listeners, and webview setup are identical between versions.

Root Cause (suspected)

This is a systemic issue with Tauri/WKWebView/macOS memory management:

  1. WKWebView keeps compositing layers even when the window is hidden (IOSurface 94.8M)
  2. JavaScriptCore GC is non-compacting — freed objects leave holes, fragmented heap grows over days
  3. JIT code pages are immortal — once compiled, never freed, accumulates with any JS execution
  4. macOS swap policy — swaps cold pages out instead of pressuring the process to compact its heap

This affects ALL Tauri/WKWebView apps that run as long-lived tray residents.

Suggested Fixes

  1. Periodic webview reload — call window.location.reload() on the settings page every N hours (e.g. 12h) when the window is hidden. This tears down and recreates the JS heap.
  2. Webview process recycle — Tauri-side, destroy and recreate the WKWebView instance periodically.
  3. Tauri upstream — investigate whether Tauri can expose WKWebView memory pressure APIs or WebPageProxy::tryClose() for idle webviews.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions