Skip to content

Running Flask’s dev server as a desktop app’s backend — what actually matters

Start a Flask app and the terminal prints a familiar line: “WARNING: This is a development server. Do not use it in a production deployment.” Yet plenty of desktop apps bundle that same local Flask server as their actual runtime and keep it running on the user’s machine for the life of the session. That looks like ignoring the warning outright, but the underlying assumptions have actually changed. This article works through what has to change for that warning to become safe to set aside — and what you still have to handle yourself, or it turns into a real bug.

Note: WSGI (Web Server Gateway Interface) is the standard interface between a Python web application and the server that runs it. Flask itself builds the WSGI application; the part that actually accepts and serves HTTP requests is a separate, swappable component. By default, development uses a lightweight built-in server (Werkzeug), while production deployments normally swap in a dedicated production WSGI server such as Gunicorn.

The warning is about an unpredictable crowd of clients

What that warning is really about is a public-facing web service: handling concurrent traffic from an unknown number of clients, minimal built-in hardening, and no multi-worker process model for load distribution. In short, “not strong enough to serve the open internet.”

Using Flask as a desktop app’s backend changes that premise entirely. The server binds only to 127.0.0.1 (loopback) and is unreachable from any external network. The only client hitting it is a single browser tab running on the same machine — not an unpredictable crowd, but one tab the user opened themselves. Under that condition, most of the dev server’s weaknesses simply stop applying.

The flip side matters just as much: accidentally binding to 0.0.0.0 makes the server reachable from any other device on the same LAN, and that premise collapses. When Flask is running as a desktop app’s backend, binding strictly to loopback has to be an explicit, deliberate choice.

Turn off the auto-reloader — it breeds a second process

Flask’s development server has a reloader that watches source files and automatically restarts the process when they change. It’s convenient during development, but under the hood it runs as two processes: a parent that watches for file changes, and a child that actually handles requests.

Leaving that enabled in a packaged desktop app causes real problems. The port number claimed at startup, and the “an instance is already running” marker file written to prevent double-launches, are both normally designed around the assumption that exactly one process is running at a time. Once the reloader spawns a parent/child pair, that assumption breaks, opening the door to duplicate port claims and confused process tracking.

# Prioritize the guarantee of a single process over reload convenience
app.run(port=port, use_reloader=False, threaded=True)

Setting use_reloader=False explicitly guarantees the app always starts as a single process. Since a desktop app’s source code never changes while it’s running, the reloader’s whole reason for existing doesn’t really apply here anyway.

Set threaded=True explicitly — or concurrent access will stall

Unless told otherwise, Flask’s development server handles requests one at a time, in sequence. But once the UI lives in a browser, it’s completely normal for multiple requests to be in flight simultaneously. Picture a long-running request streaming maintenance progress alongside a short heartbeat request sent every few dozen seconds to confirm the browser tab is still alive — while the server is busy serving the long request, the short one just sits in a queue.

# Without threaded=True, a short request can get stuck behind
# a long-running one instead of being served concurrently
app.run(port=port, use_reloader=False, threaded=True)

Setting threaded=True makes each request get handled on its own thread, sidestepping that queuing problem.

Concurrent threads mean you now have to guard shared state

Once threaded=True lets multiple requests run at the same time, a different problem shows up: multiple request handlers can now read and write the same global variable concurrently.

Say there’s a global variable tracking “is a maintenance job currently running.” If one request is in the middle of a two-step “check whether one is running, and if not, start a new one” sequence, and another thread reads and writes that same variable at the same moment, both can conclude “nothing is running” and each kick off a job — a classic check-and-set race condition.

from threading import Lock

_maint_lock = Lock()
_maint_process = None

def start_maintenance():
    with _maint_lock:
        if _maint_process is not None:
            return False  # already running
        # start the new process while still holding the lock
        ...
        return True

The fix is straightforward: protect the shared variable with a threading.Lock so the “check, then write” sequence becomes one indivisible operation. It’s worth noting this operates at a different layer than cross-platform file locking with fcntl/msvcrt, which guards against races between separate processes. This is about races between separate threads inside the same process — and once you choose threaded=True, guarding against that becomes mandatory, not optional.

You own port allocation and duplicate-instance detection

In a production application server, controlling multiple instances and negotiating ports is usually somebody else’s job — a process manager like systemd, or a container orchestrator. A Flask backend embedded in a desktop app has no such external supervisor, so you have to build that logic yourself.

Hardcoding a fixed port makes a simple failure very likely: launch the app twice, and the second attempt can’t bind because the first process is still holding the port. Instead, scan for a free port at startup and write the chosen port number and process ID to a marker file, so the next launch can check whether an instance is already running.

import socket

def find_free_port(start=5002, end=5100):
    for port in range(start, end):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(('127.0.0.1', port))
                return port
            except OSError:
                continue
    raise OSError(f"No free port found in range {start}-{end}")

When an existing instance is detected, rather than just failing with an error, it’s worth reopening a browser tab pointed at that instance’s port and quietly exiting. From the user’s point of view, clicking the app icon a second time just brings the original screen back — which is the behavior people expect.

The server has no way of knowing a browser tab was closed

With a native windowed app, the OS notifies the app the instant the user closes its window. But when a browser tab is the UI, closing that tab gives the backend Flask process no signal whatsoever. Without any countermeasure, the backend process just keeps running in the background even after the user closes the tab.

A periodic “heartbeat” request from the browser fills that gap.

_last_heartbeat_time = 0.0

@app.route('/api/heartbeat', methods=['POST'])
def api_heartbeat():
    global _last_heartbeat_time
    _last_heartbeat_time = time.time()
    return ('', 204)

The browser hits this endpoint every few dozen seconds, and a background thread on the server periodically checks how long it’s been since the last heartbeat arrived. Once the tab closes, the heartbeats stop, and after a set period of silence the server treats that as “the user closed the tab” and shuts itself down. Guarding against shutting down mid-maintenance-job, and excluding the initial warm-up window while the browser is still connecting, both matter for avoiding false positives in real use.

Summary

Setting / mechanism Purpose
Bind to 127.0.0.1 only Blocks reachability from any external network
use_reloader=False Guarantees a single running process
threaded=True Keeps a long request from stalling short ones
Guard shared state with threading.Lock Prevents check-and-set races between threads
Scan for a free port + record the running instance Detects and avoids duplicate launches
Heartbeat monitoring + timeout self-shutdown Indirectly detects that the tab was closed

Flask’s development server isn’t something to avoid outright — it’s easier to work with once you understand exactly what assumptions that warning is built on, and confine its use to environments where those assumptions genuinely hold. Loopback-only binding, a single process, thread safety, duplicate-launch detection, and liveness checks: handle each of these deliberately, and the dev server becomes a stable enough foundation to run a desktop app’s backend on.

The design decision behind choosing a Flask-plus-browser structure in the first place is covered separately in why we built a desktop app on local Flask + browser UI. This article picks up from there, covering the operational details worth handling once that structure has already been chosen.