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

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.
  • 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, 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.


# 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:

// 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. 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. This prevents race conditions where the renderer attempts API calls before the backend is ready.


# 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:

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:

// 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.

// 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) 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) 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →