# How to Spawn and Manage a FastAPI Backend as a Subprocess in Electron

> Learn how to spawn and manage a FastAPI backend as a subprocess within your Electron app. This guide covers using child_process, health checks, and graceful shutdown for seamless integration.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: how-to-guide
- Published: 2026-08-21

---

**You can spawn a FastAPI backend as a subprocess in Electron by using Node’s `child_process` module to launch a Python process, polling a `/health` endpoint to confirm readiness, and registering cleanup handlers to terminate the process on app exit.**

The Modly repository demonstrates a production-ready pattern for integrating Python AI inference with an Electron desktop UI. By isolating the FastAPI server as a subprocess, the Node.js main process remains responsive while the Python backend handles heavy computational work on `localhost:8765`.

## Architecture Overview

The implementation separates concerns across four distinct layers:

- **Electron Main Process**: Orchestrates the UI, manages the browser window, and spawns the Python subprocess using Node’s `child_process` API.
- **FastAPI Backend**: Runs as an isolated Python process exposing HTTP endpoints (`/health`, `/model/*`, `/generate/*`) defined in [`api/main.py`](https://github.com/lightningpixel/modly/blob/main/api/main.py).
- **Renderer Process**: Communicates with the backend exclusively over HTTP via `fetch` or `axios`, never interacting directly with the Python process.
- **Process Manager**: Monitors the subprocess lifecycle, performs health checks, and ensures clean termination when the application quits.

## Starting the Python Subprocess from Node.js

The core logic for launching the backend resides in [`tools/modly-cli/agent.py`](https://github.com/lightningpixel/modly/blob/main/tools/modly-cli/agent.py), specifically within the `_start_backend` function (lines 41‑53). This helper constructs a `subprocess.Popen` call with platform-specific flags to ensure the process runs independently when detached.

```python

# tools/modly-cli/agent.py

def _start_backend(cmd: list[str], *, api_dir: Path, env: dict[str, str], detach: bool) -> subprocess.Popen[Any]:
    kwargs: dict[str, Any] = {"cwd": str(api_dir), "env": env}
    if detach:
        kwargs.update({
            "stdin": subprocess.DEVNULL,
            "stdout": subprocess.DEVNULL,
            "stderr": subprocess.DEVNULL,
        })
        if os.name != "nt":
            kwargs["start_new_session"] = True  # POSIX: new session → independent

        else:
            kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
    return subprocess.Popen(cmd, **kwargs)

```

To replicate this in Electron’s main process, invoke the Python executable with Uvicorn targeting [`api/main.py`](https://github.com/lightningpixel/modly/blob/main/api/main.py):

```javascript
// electron/main/backend.ts
import { spawn } from "child_process";
import path from "path";
import { app } from "electron";

function startFastApiBackend(detach = false) {
  const python = process.env.PYTHON || "python";
  const apiDir = path.resolve(__dirname, "../../api");
  
  const env = {
    ...process.env,
    EXTENSIONS_DIR: path.join(app.getPath('userData'), "extensions"),
    SELECTED_MODEL_ID: "",
    HUGGING_FACE_HUB_TOKEN: userToken  // injected from secure storage
  };

  const cmd = [
    python,
    "-m", "uvicorn",
    "main:app",
    "--host", "127.0.0.1",
    "--port", "8765"
  ];

  const opts = { cwd: apiDir, env };
  if (detach) {
    opts.stdio = "ignore";
    opts.detached = true;
  }

  const proc = spawn(cmd[0], cmd.slice(1), opts);
  if (detach) proc.unref();  // let OS reap when Electron exits
  return proc;
}

```

The command array executes `python -m uvicorn main:app`, pointing Uvicorn to the `app` instance defined in [`api/main.py`](https://github.com/lightningpixel/modly/blob/main/api/main.py). The `cwd` option ensures Python resolves imports relative to the API directory.

## Health Checks and Readiness Polling

After spawning the subprocess, verify the FastAPI server has bound to port 8765 by polling the `/health` endpoint defined in [`api/routers/status.py`](https://github.com/lightningpixel/modly/blob/main/api/routers/status.py). This prevents race conditions where the renderer attempts API calls before the backend is ready.

```python

# api/routers/status.py

@router.get("/health")
async def health():
    """Health check — used by Electron to know the API is ready."""
    return {"status": "ok"}

```

Implement a polling loop in the Electron main process:

```javascript
async function waitForBackend(baseUrl = "http://127.0.0.1:8765", timeout = 5000) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    try {
      const r = await fetch(`${baseUrl}/health`);
      const json = await r.json();
      if (json.status === "ok") return true;
    } catch (_) { /* server not up yet */ }
    await new Promise(res => setTimeout(res, 200));
  }
  throw new Error("FastAPI backend failed to start");
}

// Usage
const backendProc = startFastApiBackend();
await waitForBackend();  // Blocks until healthy

```

The renderer process can also consume this endpoint directly to display connection status:

```typescript
// Renderer React hook example
export function useBackendHealth() {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    fetch("http://127.0.0.1:8765/health")
      .then(r => r.json())
      .then(({ status }) => setReady(status === "ok"))
      .catch(() => setReady(false));
  }, []);
  return ready;
}

```

## Graceful Shutdown and Process Cleanup

Store the subprocess reference returned by `spawn()` to terminate the backend when Electron exits. According to the source analysis, you must handle platform differences: POSIX systems use `SIGTERM`, while Windows requires `CTRL_BREAK_EVENT`.

```javascript
// Store the process handle
let backendProc = null;

export function startBackend() {
  backendProc = startFastApiBackend();
  return waitForBackend();
}

// Cleanup handlers
app.on("before-quit", () => {
  if (backendProc && !backendProc.killed) {
    backendProc.kill();  // Sends SIGTERM on POSIX, CTRL_BREAK_EVENT on Windows
  }
});

app.on("quit", () => {
  if (backendProc) backendProc = null;
});

```

For detached processes started with `start_new_session` (POSIX) or `CREATE_NEW_PROCESS_GROUP` (Windows), the OS handles reaping when the parent exits, though explicit cleanup remains best practice.

## Summary

- **Use `child_process.spawn`** to launch `uvicorn` from the Electron main process, setting `cwd` to the Python API directory.
- **Pass environment variables** like `EXTENSIONS_DIR` and `HUGGING_FACE_HUB_TOKEN` via the `env` option to configure the backend.
- **Poll `/health`** (defined in [`api/routers/status.py`](https://github.com/lightningpixel/modly/blob/main/api/routers/status.py)) to guarantee the FastAPI application is accepting connections before loading the UI.
- **Handle platform differences** when detaching processes: use `start_new_session` on POSIX and `CREATE_NEW_PROCESS_GROUP` on Windows.
- **Cleanup on exit** by storing the subprocess handle and calling `.kill()` during Electron’s `before-quit` event to prevent zombie Python processes.

## Frequently Asked Questions

### How do I pass environment variables to the FastAPI subprocess?

Supply a key-value object to the `env` option in `child_process.spawn`. In the Modly implementation, variables like `EXTENSIONS_DIR` and `HUGGING_FACE_HUB_TOKEN` are injected this way to configure extension loading and authentication without hardcoding secrets.

### What is the default port for the FastAPI backend in this setup?

The backend listens on `127.0.0.1:8765` by default. This is hardcoded in the Uvicorn command arguments (`--port 8765`) and mirrored in the Electron health-check polling logic. You can modify this by changing both the spawn arguments and the fetch URL in the renderer.

### How does the Electron app know when the Python backend is ready?

The main process polls the `/health` endpoint (implemented in [`api/routers/status.py`](https://github.com/lightningpixel/modly/blob/main/api/routers/status.py)) until it returns `{"status": "ok"}`. This occurs after the Uvicorn server has fully initialized but before the renderer loads, ensuring all API calls succeed immediately.

### What happens if I don't kill the subprocess when Electron exits?

Without explicit cleanup in the `before-quit` handler, the Python process may continue running as a zombie or orphan, consuming port 8765 and system resources. The Modly source code prevents this by storing the `Popen` instance and invoking `.kill()`, which sends the appropriate termination signal for the operating system.