Flet vs Reflex: Which Python Framework Wins for Desktop Apps?
I built production tools with both. Here’s where each framework shines and where it quietly gets in your way.
Flet vs Reflex: Which Python Framework Wins for Desktop Apps?
I built production tools with both. Here’s where each framework shines and where it quietly gets in your way.

Choosing between Flet and Reflex for your next Python desktop application? Explore production-ready architectures, performance tradeoffs, deployment strategies, and real engineering patterns.
Flet vs Reflex: Which Python Framework Wins for Desktop Apps?
There was a time when building desktop software with Python almost guaranteed one thing: a terrible user interface.
Tkinter looked ancient.
PyQt was incredibly powerful but carried licensing questions and a steep learning curve.
Kivy solved some problems while introducing several new ones.
Meanwhile, web development moved at an incredible pace. Beautiful interfaces became easier to build every year, while desktop applications remained stuck in the early 2000s.
Then something interesting happened.
Instead of fighting native desktop UI toolkits, newer Python frameworks started asking a different question:
What if the desktop app was simply another frontend rendered from Python?
That idea gave birth to frameworks like Flet and Reflex.
Although they target similar developers Python engineers wanting to build interfaces they couldn’t be more different under the hood.
After shipping internal dashboards, automation tools, and operational software using both frameworks, I realized choosing between them has very little to do with syntax.
It has everything to do with architecture.
And architecture always wins in production.
Two Frameworks, Two Completely Different Philosophies
Many comparison articles reduce this discussion to feature checklists.
That’s the wrong comparison.
The real distinction is architectural.
Flet
Flet treats Python as the application runtime.
The UI is described in Python.
The framework communicates with Flutter, which renders the interface.
Your Python code stays alive during the application’s lifetime.
Python Application
│
│ State Updates
▼
Flet Runtime Server
│
▼
Flutter Engine
│
▼
Desktop Window
The developer rarely writes JavaScript.
Rarely touches HTML.
Almost never thinks about frontend build systems.
For backend engineers, this feels incredibly natural.
Reflex
Reflex approaches the problem differently.
Python defines the application.
Then Reflex generates a modern React application underneath.
Your Python becomes a React frontend plus backend communication.
Python Code
│
▼
Reflex Compiler
│
▼
React Frontend
│
REST/WebSocket
│
▼
Python Backend
Instead of hiding the web stack…
Reflex embraces it.
And that’s an important distinction.
Why Architecture Matters More Than Features
Imagine you’re building an inventory management platform.
The requirements seem innocent enough.
- Authentication
- Live dashboard
- Product search
- Analytics
- Reports
- Background synchronization
- PostgreSQL
- File uploads
- Role management
Nothing unusual.
Now imagine six developers working on it.
Architecture starts mattering immediately.
A framework isn’t just about building screens.
It’s about how maintainable those screens remain after twelve months.
Production Project Structure
Whether using Flet or Reflex, I avoid dumping everything into a single file.
Instead:
inventory_system/
├── app/
│ ├── api/
│ ├── services/
│ ├── repositories/
│ ├── models/
│ ├── schemas/
│ ├── auth/
│ ├── workers/
│ ├── ui/
│ └── config.py
│
├── tests/
│
├── docker/
│
├── alembic/
│
├── main.py
│
└── pyproject.toml
One mistake I see repeatedly is treating desktop applications differently from backend systems.
Don’t.
Desktop software deserves clean architecture too.
A Shared Backend Changes Everything
One of my favorite production patterns is separating the UI entirely from business logic.
Instead of embedding database code inside UI callbacks:
Bad
def save_product(e):
conn = psycopg.connect(...)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO products VALUES (%s)",
(name_input.value,)
)
conn.commit()
This works.
Until it doesn’t.
Database logic slowly spreads across dozens of UI files.
Testing becomes painful.
Changing databases becomes painful.
Observability disappears.
A better approach:
UI
↓
Service Layer
↓
Repository Layer
↓
Database
Now the UI simply requests an operation.
Repository
class ProductRepository:
def __init__(self, session):
self.session = session
async def create(self, product):
self.session.add(product)
await self.session.commit()
return product
Service
class ProductService:
def __init__(self, repository):
self.repository = repository
async def create_product(self, request):
product = Product(
name=request.name,
quantity=request.quantity
)
return await self.repository.create(product)
UI Layer
async def create_product_clicked(e):
request = ProductRequest(
name=name.value,
quantity=int(quantity.value)
)
await product_service.create_product(request)
Notice what disappeared.
No SQL.
No transactions.
No persistence logic.
The UI became incredibly boring.
That’s exactly what we want.
Boring UI usually means maintainable software.
Production Backend with FastAPI
Whether the frontend is Flet or Reflex, I almost always expose business logic through APIs.
Desktop UI
↓
FastAPI
↓
Services
↓
Repositories
↓
PostgreSQL
FastAPI endpoint:
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("/products")
async def create_product(
request: ProductRequest,
service: ProductService = Depends()
):
return await service.create_product(request)
Now every client benefits.
- Desktop
- Web
- Mobile
- CLI
- Automation scripts
The backend doesn’t care.
Database Models
SQLAlchemy 2.0 keeps everything explicit.
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
quantity: Mapped[int]
Simple.
Predictable.
Easy to migrate.
Pydantic Validation
One of the easiest bugs to avoid is invalid data entering your system.
from pydantic import BaseModel, Field
class ProductRequest(BaseModel):
name: str = Field(min_length=2)
quantity: int = Field(gt=0)
Validation belongs at system boundaries.
Not scattered throughout your UI.
Flet Example: Building the Desktop Screen
Flet feels remarkably intuitive if you’ve spent years writing backend code.
import flet as ft
def main(page: ft.Page):
page.title = "Inventory Manager"
name = ft.TextField(
label="Product"
)
quantity = ft.TextField(
label="Quantity"
)
result = ft.Text()
async def save(e):
response = await api.create_product(
name=name.value,
quantity=int(quantity.value)
)
result.value = response["message"]
page.update()
page.add(
name,
quantity,
ft.ElevatedButton(
"Save",
on_click=save
),
result
)
ft.app(target=main)
There isn’t much magic here.
That’s one of Flet’s biggest strengths.
Most backend developers become productive within hours.
Keeping the UI Responsive
A common production mistake is executing slow work directly from button clicks.
Bad:
def sync_orders(e):
orders = fetch_100000_orders()
process_orders(orders)
page.update()
The application freezes.
Users think it crashed.
Instead:
async def sync_orders(e):
await background_service.sync()
snackbar.open = True
page.update()
Even better:
Button Click
↓
Queue Job
↓
Worker
↓
Database
↓
Notification
Long-running work belongs in workers — not the UI thread.
Background Processing with Celery
@celery.task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=5
)
def sync_inventory():
inventory_service.sync()
The desktop application simply triggers:
sync_inventory.delay()
Instant response.
Reliable retries.
No frozen windows.
Structured Logging
Console prints disappear in production.
Logs shouldn’t.
import structlog
logger = structlog.get_logger()
logger.info(
"product_created",
product_id=product.id,
user=current_user.id
)
When users report issues, structured logs become invaluable.
Configuration Management
Hardcoding secrets is one of the fastest ways to create deployment headaches.
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str
secret_key: str
settings = Settings()
Now your application behaves consistently across development, staging, and production environments.
Health Checks Matter for Desktop Apps Too
Even desktop applications often depend on external services.
A lightweight health endpoint helps diagnose issues before users notice them.
@router.get("/health")
async def health():
return {
"database": "ok",
"redis": "ok",
"api": "healthy"
}
A Flet client can periodically check this endpoint and display connection status without blocking the UI.
Reflex, Performance, Deployment, and the Verdict
If you’ve ever worked on a product that started as an internal dashboard and later became customer-facing, you’ve probably noticed something interesting.
The frontend slowly becomes more complicated than the backend.
Authentication.
Routing.
Live updates.
Charts.
Animations.
State synchronization.
Responsive layouts.
Dark mode.
Offline handling.
The backend keeps exposing APIs.
The frontend keeps growing.
This is exactly where Reflex starts separating itself from Flet.
Reflex Feels Like Building a Modern Web Application
Unlike Flet, Reflex doesn’t maintain a persistent Flutter runtime.
Instead, it generates a modern React application while allowing you to write Python.
That architectural decision changes almost everything.
User
↓
Browser/Desktop
↓
React Components
↓
WebSocket
↓
Python Backend
↓
PostgreSQL
You’re no longer building a desktop application.
You’re building a web application that can also behave like one.
That difference sounds subtle.
In production, it isn’t.
A Simple Reflex Page
Reflex organizes UI around reactive state.
import reflex as rx
class InventoryState(rx.State):
products: list[str] = []
product_name: str = ""
async def add_product(self):
if self.product_name:
self.products.append(self.product_name)
self.product_name = ""
def index():
return rx.vstack(
rx.heading("Inventory"),
rx.input(
value=InventoryState.product_name,
on_change=InventoryState.set_product_name,
),
rx.button(
"Add",
on_click=InventoryState.add_product
),
rx.foreach(
InventoryState.products,
lambda item: rx.text(item)
)
)
app = rx.App()
app.add_page(index)
If you’ve worked with React before, this feels familiar.
If you haven’t, Reflex still hides most of the JavaScript complexity.
The State Management Difference
This is probably the biggest architectural distinction.
Flet
Python
↓
Widget State
↓
Flutter UI
Everything lives inside Python.
Very little frontend state management.
Reflex
Python
↓
Generated React State
↓
Browser
↓
Backend Synchronization
The frontend becomes much smarter.
That opens more possibilities.
It also introduces more moving parts.
Every abstraction removes one problem and introduces another.
Authentication Done Properly
Regardless of the framework, authentication belongs in the backend.
Never inside UI components.
Using JWT with FastAPI:
from datetime import timedelta
from jose import jwt
def create_access_token(user_id: int):
payload = {
"sub": str(user_id)
}
return jwt.encode(
payload,
settings.secret_key,
algorithm="HS256"
)
Protected endpoint:
@router.get("/profile")
async def profile(
current_user=Depends(get_current_user)
):
return current_user
Desktop frameworks should consume APIs.
They shouldn’t become authentication systems.
Redis Makes Desktop Applications Feel Instant
Many desktop tools repeatedly fetch the same data.
Dashboards.
Analytics.
Configuration.
Reference tables.
Without caching:
User
↓
API
↓
Database
Every request hits PostgreSQL.
With Redis:
User
↓
Redis
↓
PostgreSQL
Much better.
Example:
async def get_dashboard():
cached = await redis.get("dashboard")
if cached:
return json.loads(cached)
data = await repository.dashboard()
await redis.setex(
"dashboard",
60,
json.dumps(data)
)
return data
Users don’t care whether your SQL query is elegant.
They care that the dashboard opens instantly.
Pagination Isn’t Optional
One production mistake appears in nearly every internal application.
Loading every row.
products = await repository.all_products()
Works.
Until the table reaches 500,000 rows.
Better:
async def list_products(
page: int,
size: int
):
offset = (page - 1) * size
return await repository.paginate(
offset,
size
)
Small optimization.
Massive difference.
AsyncIO Keeps Everything Responsive
Modern desktop software constantly communicates with APIs.
Using async correctly matters.
async def refresh():
inventory = inventory_client.fetch()
orders = order_client.fetch()
analytics = analytics_client.fetch()
return await asyncio.gather(
inventory,
orders,
analytics
)
Instead of waiting three times…
Everything happens together.
Database Transactions
Never trust partial writes.
async with session.begin():
product = Product(...)
session.add(product)
session.add(AuditLog(...))
Either everything succeeds.
Or nothing changes.
Production systems should never leave inconsistent data behind.
Idempotency Saves You From Double Clicks
Users double-click buttons.
Networks retry requests.
Browsers resend forms.
Your API must survive it.
@router.post("/orders")
async def create_order(
request: OrderRequest,
idempotency_key: str = Header(...)
):
existing = await repository.find_key(
idempotency_key
)
if existing:
return existing
return await service.create(
request,
idempotency_key
)
Most duplicate orders aren’t user mistakes.
They’re retry mistakes.
Docker Makes Deployment Predictable
A simple production Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn","app.main:app","--host","0.0.0.0"]
Compose:
version: "3.9"
services:
api:
build: .
ports:
- "8000:8000"
postgres:
image: postgres:16
redis:
image: redis:7
Every environment becomes identical.
No more:
“It works on my laptop.”
Observability Wins More Bugs Than Debugging
Logging helps.
Tracing explains.
OpenTelemetry:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"create_product"
):
await service.create_product(request)
Instead of guessing where latency comes from…
You’ll know.
Rate Limiting Protects APIs
Desktop software can accidentally overload its own backend.
Especially after reconnecting.
from slowapi import Limiter
limiter = Limiter()
@router.get("/inventory")
@limiter.limit("60/minute")
async def inventory():
...
One reconnect storm shouldn’t take down production.
Circuit Breakers Prevent Cascading Failures
Imagine the payment provider goes offline.
Without protection:
Desktop
↓
Payment API
↓
Timeout
↓
Timeout
↓
Timeout
With a circuit breaker:
if breaker.is_open():
return cached_response()
response = payment_client.pay()
Your application stays responsive instead of hanging.
Flet Performance in Production
Flet is surprisingly efficient.
Especially for:
- Internal tools
- Admin dashboards
- ERP systems
- Warehouse software
- POS systems
- Automation panels
- Monitoring consoles
Its biggest strength is simplicity.
Backend engineers feel productive almost immediately.
The downside appears when frontend complexity increases.
Large animations.
Complex routing.
Highly interactive web experiences.
Those aren’t Flet’s strongest areas.
Reflex Performance in Production
Reflex inherits many strengths from modern React.
That means:
- Better browser experience
- Rich UI
- Responsive layouts
- Component ecosystem
- Easier web deployment
The tradeoff?
More frontend machinery.
Compilation.
Generated React code.
More dependencies.
More layers.
Nothing is free.
Deployment Comparison
Flet
Python
↓
Flutter Runtime
↓
Desktop Executable
Excellent for desktop-first products.
Reflex
Python
↓
React Build
↓
Web
↓
Desktop Wrapper (Optional)
Excellent for applications targeting both browser and desktop.
Feature Comparison

When I Choose Flet
I choose Flet when I’m building:
- Internal business software
- Inventory systems
- Manufacturing dashboards
- HR tools
- Admin panels
- Monitoring consoles
- Local automation software
- Desktop utilities
Because development is incredibly fast.
The codebase stays simple.
Maintenance costs stay low.
When I Choose Reflex
I reach for Reflex when:
- The application must live in the browser
- Responsive design matters
- Marketing pages coexist with the app
- SEO matters
- The UI resembles a modern SaaS product
- Teams may later customize React components
Reflex feels like a long-term investment in web architecture.
So… Which Framework Wins?
The answer depends less on the framework and more on the product you’re building.
Choose Flet if:
- Your users primarily need a desktop application.
- Your team is backend-heavy and wants to stay in Python.
- Fast development and low complexity matter more than advanced frontend customization.
Choose Reflex if:
- Your application needs to thrive on the web as well as desktop.
- You expect the interface to grow into a sophisticated SaaS experience.
- You’re comfortable embracing a React-powered architecture without writing much JavaScript.
The most successful engineering teams don’t chase frameworks.
They optimize for maintenance.
Six months after launch, nobody celebrates that you picked the trendiest UI toolkit.
They celebrate that deployments are predictable, bugs are easy to trace, and new features don’t require rewriting half the codebase.
Frameworks accelerate development. Architecture determines longevity.
And after building production systems with both, that’s the distinction that matters most.
메타데이터
- post_id
- c9740e973ec0
- slug
- flet-vs-reflex-which-python-framework-wins-for-desktop-apps-c9740e973ec0
- url
- https://medium.com/@komalbaparmar007/flet-vs-reflex-which-python-framework-wins-for-desktop-apps-c9740e973ec0
- canonical_url
- https://medium.com/@komalbaparmar007/flet-vs-reflex-which-python-framework-wins-for-desktop-apps-c9740e973ec0
- author_url
- https://medium.com/@komalbaparmar007
- status
- ok
- fetched_at
- 2026-07-13 11:29:01