ScreenshotNeo

BlogEngineering

How to Stop Electron Processes After Closing and Prevent Excessive Resource Usage

Learn why Electron stays running after a window closes, how to quit cleanly, and how to find CPU and memory usage without breaking platform conventions.

By the ScreenshotNeo team1 October 20267 min read

How to Stop Electron Processes After Closing and Prevent Excessive Resource Usage

Closing an Electron window does not always quit the application. On Windows and Linux, apps generally quit after their final window closes. On macOS, apps commonly keep running without windows so they can reopen quickly when activated. Your event handlers, background work, and shutdown method decide what remains.

To stop an Electron app after its last window closes, handle window-all-closed and call app.quit() when that matches your product design. Then inspect remaining processes with app.getAppMetrics(), measure CPU over an interval, and clear timers, child processes, and other app-owned work during orderly shutdown.

What “close” means in Electron

There are several different actions that users describe as closing:

A closed window can leave main, utility, or child processes running until their work is cleaned up.
A closed window can leave main, utility, or child processes running until their work is cleaned up.
Action What it does Why a process may remain
Close the last window Destroys that BrowserWindow and its renderer. Electron may keep the main process alive, especially on macOS.
Hide a window Removes it from view without destroying it. The renderer and its JavaScript continue running.
Choose Quit Requests an orderly application shutdown. beforeunload, before-quit, or other handlers can delay or cancel it.
Force-terminate the process Stops immediately through the operating system. Cleanup and unsaved-state handling may be skipped.

Electron’s app API documents that app.quit() attempts to close all windows, emits before-quit, and then emits will-quit if windows close successfully. A window’s beforeunload handler can cancel the close. app.exit() closes immediately and skips before-quit and will-quit.

Choose the intended lifecycle for each platform

Electron’s first-app tutorial describes the usual convention: Windows and Linux applications quit when all windows close, while macOS applications remain active and commonly recreate a window from the activate event.

Quit on Windows and Linux, stay active on macOS

const { app, BrowserWindow } = require('electron')

function createWindow () {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      contextIsolation: true
    }
  })

  win.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

This is the common cross-platform pattern. On macOS, closing the final window leaves the app available in the dock; activating it creates a new window. On Windows and Linux, the final window closing requests an orderly quit.

Quit on every platform

app.on('window-all-closed', () => {
  app.quit()
})

Use this only when quitting on macOS is part of your intended behavior. A macOS process with no windows is not automatically a leak.

Check the handlers that prevent shutdown

Search the main-process source for window-all-closed, before-quit, will-quit, preventDefault(), and calls to app.exit(). Search renderer code for beforeunload and event.returnValue = false.

window.addEventListener('beforeunload', (event) => {
  if (hasUnsavedChanges()) {
    event.preventDefault()
    event.returnValue = false
  }
})

A handler like this can intentionally keep a window open for a save confirmation. If it is left behind after the feature is removed, it can make a normal quit appear broken. Log every path that calls preventDefault() and make the user decision explicit.

Clean up timers, polling, and child work

Destroying a BrowserWindow terminates its renderer, but the main process can still own intervals, sockets, utility processes, and child processes. Electron’s process model explains that an app can contain multiple process types. Stop work that is owned by the app when quitting.

Electron CPU values are interval averages, so compare samples taken over a known period.
Electron CPU values are interval averages, so compare samples taken over a known period.
const { app } = require('electron')

let pollTimer
let child

function startBackgroundWork () {
  pollTimer = setInterval(() => {
    // Poll only while the app is intended to stay active.
  }, 30_000)
}

function stopBackgroundWork () {
  if (pollTimer) {
    clearInterval(pollTimer)
    pollTimer = undefined
  }

  if (child && !child.killed) {
    child.kill()
    child = undefined
  }
}

app.on('before-quit', () => {
  stopBackgroundWork()
})

Electron’s progress-bar tutorial shows the same cleanup principle by clearing a repeating interval from a before-quit listener. If you use app.exit(), that listener will not run, so do not rely on immediate exit when cleanup is required.

Measure which process uses CPU or memory

Call app.getAppMetrics() from the main process. It returns process-level CPU and memory information so you can identify whether the remaining process is the main process, a renderer, a GPU process, or another process.

const { app } = require('electron')

function sampleMetrics (label) {
  const metrics = app.getAppMetrics().map((item) => ({
    label,
    pid: item.pid,
    type: item.type,
    cpu: item.cpu,
    memory: item.memory
  }))

  console.table(metrics)
}

app.whenReady().then(() => {
  sampleMetrics('first')

  setTimeout(() => {
    sampleMetrics('after-5-seconds')
  }, 5000)
})

Electron documents that percentCPUUsage is an average since the previous call and that the first reading is zero. Take two readings over a known interval; do not treat the first value as an instantaneous measurement. A process that remains alive is not proof of excessive usage. Check its type, memory, and interval CPU while the app is idle and while the suspected feature is active.

Record a useful diagnostic snapshot

  1. Close the final window or choose Quit, depending on the report.
  2. Wait a consistent interval, such as five seconds.
  3. Capture pid, process type, CPU percentage, and memory for each metric.
  4. Repeat after stopping timers or disabling the suspected feature.
  5. Compare the process list with the operating system’s Task Manager or Activity Monitor.

Also inspect any processes launched with Node’s child_process APIs, Electron utility processes, native modules, and external helpers. A renderer that disappeared while a helper remains points to a different shutdown path.

Use app.quit() versus app.exit()

Method Behavior Use when
app.quit() Closes windows and runs normal quit lifecycle events. You need orderly cleanup, save prompts, and predictable shutdown.
app.exit(code) Closes immediately and skips before-quit and will-quit. Immediate termination is explicitly intended and cleanup consequences are handled.

Prefer app.quit() for normal user-initiated quitting. Reserve app.exit() for cases where bypassing lifecycle handlers is deliberate. Neither method should be used to hide an unresolved timer, socket, or child-process bug.

Common causes and fixes

Symptom Likely cause Fix
macOS app remains in the dock after the window closes Platform convention or an activate workflow. Keep it if background-ready behavior is intended; otherwise call app.quit() from window-all-closed.
Windows/Linux process remains after the last window A custom window-all-closed handler does not quit. Add if (process.platform !== 'darwin') app.quit() or unconditional quit according to product requirements.
Quit hangs with a save prompt A renderer beforeunload handler cancels closing. Resolve the unsaved state, remove stale cancellation logic, or make the prompt path explicit.
CPU rises after closing the window Main-process interval, polling loop, socket, or child process continues. Track ownership and stop it in before-quit; verify with interval metric samples.
Memory stays high A main, utility, GPU, or child process remains, or retained objects are still referenced. Identify the process type and PID with app.getAppMetrics(), then inspect the code that owns it.
Cleanup never runs The app uses app.exit(). Use app.quit() for normal shutdown or invoke required cleanup before immediate exit.
Only one feature leaves a process That feature starts external work independently of the window. Keep a handle to the process or resource and close it when the feature is disabled or the app quits.

Performance and reliability checklist

  • Define whether closing a window means hide, destroy, or quit on each operating system.
  • Keep one owner for every interval, socket, child process, and utility process.
  • Clear recurring work from an idempotent shutdown function.
  • Use app.getAppMetrics() twice over a known interval.
  • Record process type and PID before changing code.
  • Test the final-window path, menu Quit, renderer save prompts, and app relaunch.
  • Confirm behavior against the Electron version your app actually uses; the online documentation pages are rolling latest references.
  • Do not set arbitrary “normal” CPU or memory thresholds without measuring your own workload.

Or skip the browser setup

If your workflow also needs screenshots of the app’s documentation, status pages, or web UI, ScreenshotNeo can capture a URL with one request instead of maintaining browser lifecycle code. See the ScreenshotNeo docs for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots, and the free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Is an Electron process after window close always a bug?

No. It is common on macOS, and any platform may intentionally keep background work alive. Check the intended lifecycle and measured activity.

Does destroying a BrowserWindow stop the whole app?

No. It terminates that window’s renderer. The main process and other process types can continue.

Why is the first CPU metric zero?

Electron reports CPU as an average since the previous call, so the first sample has no interval to measure.

Can I fix a leak by calling app.exit()?

It can hide the symptom by terminating immediately, but it skips normal quit events and cleanup. Find and stop the work that keeps the process active.

Which Electron API should I verify for my release?

Use the API documentation matching your installed Electron version and confirm lifecycle behavior on every supported operating system.