Resetting Spark Desktop's Memory Bloat Every Night with Task Scheduler

Resetting Spark Desktop's Memory Bloat Every Night with Task Scheduler


Electron-based resident apps eat memory. Slack, the Claude desktop app, and Spark Desktop. Each one carries a full Chromium stack, so just leaving them resident piles up several GB of commit in total.

First I measured the reality.

Get-Process | Where-Object {$_.Name -match "Slack|Spark|Claude"} |
  Group-Object Name |
  Select-Object Name, Count,
    @{n='WS_MB';e={[math]::Round(($_.Group | Measure-Object WorkingSet64 -Sum).Sum/1MB)}},
    @{n='PrivateMB';e={[math]::Round(($_.Group | Measure-Object PrivateMemorySize64 -Sum).Sum/1MB)}}

The result: Spark Desktop was 7 processes at 1.7GB Private. Private being larger than Working Set (1.1GB) means it is heavily paged out yet the commit is heavy — the kind of bloat where mail bodies and attachment caches accumulate inside the renderers. This sort of bloat progresses over long uptime, so the countermeasure comes down simply to "restart it periodically." So I built a mechanism to restart it every night via Task Scheduler.

Approach

The restart itself takes three lines.

Stop-Process -Name "Spark Desktop" -Force
Start-Sleep -Seconds 3
Start-Process "$env:LOCALAPPDATA\Programs\SparkDesktop\Spark Desktop.exe"

But real-world use had a few issues.

  1. Restarting opens the main window. Since this is an unattended task running in the middle of the night, I want it back in the tray-resident state.
  2. On days when I have deliberately quit Spark, I do not want it launching on its own.
  3. For measuring the effect, I want to log the memory usage before each restart.

Hidden launch arguments did not work

Electron tray-resident apps often have hidden launch arguments like --hidden or --minimized. I tried that first.

Start-Process "$env:LOCALAPPDATA\Programs\SparkDesktop\Spark Desktop.exe" -ArgumentList "--hidden"

No luck. The window opened normally. Spark Desktop (as of 3.30.1) does not seem to support this kind of argument.

Incidentally, in my first test an existing instance was still running when I launched with --hidden, so the single-instance check ("already running. Exiting") kicked in and the test was inconclusive. This kind of verification must always be done from a clean state, with the process fully killed.

Sending it to the tray with WM_CLOSE

If arguments are out, then wait for the window to appear after launch and close it. There are options for how to close it, but ShowWindow(SW_HIDE) can get out of sync with the app's own display-state management, so I chose to send WM_CLOSE — the same as the user pressing the × button. Spark is the "closing leaves it in the tray" type, so this naturally makes it tray-resident.

From PowerShell I call PostMessage via P/Invoke.

Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
    [DllImport("user32.dll")]
    public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
}
"@

Two points to watch.

First, Electron apps have a long startup sequence. For Spark, it takes more than 10 seconds from exe launch to bootstrap completion. Sending WM_CLOSE right after the window handle appears can be ignored, so I wait another 5 seconds after detecting the handle and re-fetch the handle just before sending (a safeguard against the case where the window gets recreated during initialization).

Second, Spark's "behavior on close" setting. If it is set to fully quit on ×, this script produces the self-defeating motion of "restart → immediate exit." In fact, in my first test Spark fully quit right after WM_CLOSE, and I ended up having to check the close-to-tray setting. To catch this accident, I added a liveness check on the process 5 seconds after sending WM_CLOSE and log it.

The finished form

# Restart-SparkDesktop.ps1
# Restart Spark Desktop only if it is running, to reset memory bloat,
# and send the window to the tray with WM_CLOSE after launch.
# If it is not running, do nothing (respect the intentionally-quit state).

$exePath = Join-Path $env:LOCALAPPDATA "Programs\SparkDesktop\Spark Desktop.exe"
$logDir  = Join-Path $env:LOCALAPPDATA "RestartSparkDesktop"
$logFile = Join-Path $logDir "restart.log"

if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }

function Write-Log($msg) {
    $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    Add-Content -Path $logFile -Value "$ts $msg"
}

# --- Launch check: if not running, do nothing ---
$procs = Get-Process -Name "Spark Desktop" -ErrorAction SilentlyContinue
if (-not $procs) {
    Write-Log "SKIP: Spark Desktop not running, nothing to do"
    exit 0
}

# --- Record the state before restart ---
$privMB = [math]::Round(($procs | Measure-Object PrivateMemorySize64 -Sum).Sum / 1MB)
$hadWindow = [bool]($procs | Where-Object { $_.MainWindowHandle -ne 0 })

Write-Log "Stopping Spark Desktop ($($procs.Count) procs, Private ${privMB}MB, window=$hadWindow)"
$procs | Stop-Process -Force
Start-Sleep -Seconds 3

if (-not (Test-Path $exePath)) {
    Write-Log "ERROR: exe not found at $exePath"
    exit 1
}

Start-Process $exePath
Write-Log "Started: $exePath"

# --- Wait for the window to appear, then send it to the tray with WM_CLOSE ---
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
    [DllImport("user32.dll")]
    public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
}
"@

$WM_CLOSE = 0x0010
$closed = $false
for ($i = 0; $i -lt 30; $i++) {
    Start-Sleep -Seconds 1
    $p = Get-Process -Name "Spark Desktop" -ErrorAction SilentlyContinue |
         Where-Object { $_.MainWindowHandle -ne 0 } |
         Select-Object -First 1
    if ($p) {
        # Wait for the renderer to initialize; too early and WM_CLOSE is ignored
        Start-Sleep -Seconds 5
        # Re-fetch the handle (the window can be recreated during init)
        $p = Get-Process -Id $p.Id -ErrorAction SilentlyContinue
        if ($p -and $p.MainWindowHandle -ne 0) {
            [Win32]::PostMessage($p.MainWindowHandle, $WM_CLOSE, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null
            Write-Log "Sent WM_CLOSE to window (PID $($p.Id))"
            $closed = $true
        }
        break
    }
}
if (-not $closed) { Write-Log "WARN: window not found within 30s, left as-is" }

# --- Liveness check: if the process is still there after WM_CLOSE, tray-resident succeeded ---
Start-Sleep -Seconds 5
if (Get-Process -Name "Spark Desktop" -ErrorAction SilentlyContinue) {
    Write-Log "OK: Spark still running (tray resident)"
} else {
    Write-Log "ERROR: Spark exited after WM_CLOSE - check close-to-tray setting"
}

Because it logs the Private memory before each restart, running it for a few days accumulates real data on "how much it swells every night."

Registering it in Task Scheduler

Because it needs to launch a GUI app into my own desktop session, the SYSTEM account cannot be used. Register it under my own account with normal privileges (RunLevel Limited). No UAC involved.

$action  = New-ScheduledTaskAction -Execute "powershell.exe" `
  -Argument '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\Users\masaf\scripts\Restart-SparkDesktop.ps1"'

$trigger = New-ScheduledTaskTrigger -Daily -At 4:00AM

$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
  -ExecutionTimeLimit (New-TimeSpan -Minutes 5)

Register-ScheduledTask -TaskName "Restart Spark Desktop" `
  -Action $action -Trigger $trigger -Settings $settings `
  -User "$env:COMPUTERNAME\masaf" -RunLevel Limited

Registering without a password makes it "run only when the user is logged on." It still runs while the screen is locked as long as the session is alive (locking and logging off are different things), so for normal use this is fine.

Bonus: the culprit behind the 45 seconds was the test script

During testing, I was troubled by "it should SKIP because Spark is stopped, yet it takes exactly 45 seconds every time." The log had only the single SKIP line, and the process count was 0. The script itself was clearly exiting immediately.

The culprit was the test wrapper I had written to verify the task-driven run.

Start-ScheduledTask -TaskName "Restart Spark Desktop"
Start-Sleep -Seconds 45
Get-Content "$env:LOCALAPPDATA\RestartSparkDesktop\restart.log" -Tail 4

Just three lines: start the task, sleep a fixed 45 seconds to wait for the result, then show the log. I was measuring this with Measure-Command, so no matter what the script did, it always took 45 seconds. A classic case of measuring the wrong thing.

I fixed the test wrapper to stop the fixed sleep and instead poll for the log file update and the task finishing.

$logFile = "$env:LOCALAPPDATA\RestartSparkDesktop\restart.log"
$before = (Get-Item $logFile).LastWriteTime

Start-ScheduledTask -TaskName "Restart Spark Desktop"

for ($i = 0; $i -lt 90; $i++) {
    Start-Sleep -Seconds 1
    $task = Get-ScheduledTask -TaskName "Restart Spark Desktop"
    if ((Get-Item $logFile).LastWriteTime -gt $before -and $task.State -ne 'Running') { break }
}

Get-Content $logFile -Tail 4

Now it returns in 4 seconds when it SKIPs.

Summary

  • For Electron resident apps, resetting memory bloat with a periodic restart is the practical approach
  • Hidden launch arguments (--hidden and the like) depend on the app; Spark Desktop did not support them
  • To hide a window, WM_CLOSE is safer than SW_HIDE — but it assumes the app's close-to-tray setting
  • Electron has a lag between the window handle and the renderer being ready, so send WM_CLOSE only after initialization completes
  • Add a "do nothing if not running" check to respect the intentionally-quit state
  • When something feels slow, first suspect whether you are measuring the right thing

Report Page