Streamlit got me to v1 in a day. It also made v2 impossible.

Building a ski trip finder in Streamlit was fast—until async operations and form inputs started fighting each other. Here's why I migrated to React.

I built a ski trip finder that combines cheap flights with snow forecasts. Streamlit got the first working version live in a single day—search form, results table, pass filters, even a snowfall animation via injected CSS. That speed was real.

A week later, I threw it out and rewrote the frontend in React.

Where it broke down

Widget state during async operations

Users could type their email to sign up for alerts. While the search was running (streaming results via st.write() in a placeholder), typing into the email field would cause the page to refresh, losing both the input and sometimes the search results.

I tried one or two fixes—explicit session state keys, flags to prevent re-triggering the search. Each fix created new problems. The underlying issue: Streamlit’s entire app reruns on every state change. When results are streaming and widgets are active, reruns collide with placeholder updates. There’s no way to tell Streamlit “update this area without touching that area.”

The frustrating part: you could see exactly what was happening, but not fix it. URL parameters were triggering reruns. The email input was triggering reruns. The streaming results were triggering reruns. Everything was fighting everything else.

At some point I realized I was asking whether this was fundamentally unfixable in Streamlit’s architecture. That was the moment I stopped debugging and started planning the migration.

No streaming granularity

The UX I wanted:

[Vail]       ████████ $287 Jan 15-18
[Breck]      ░░░░░░░░ loading...
[Park City]  ████████ $312 Jan 16-19

The UX Streamlit could give me:

Loading... [████████████░░░░░░░░] 60%
(entire table renders at once when done)

Streamlit’s progress bars aren’t tied to actual completion. They’re visual flourishes you manually update. You can’t show partial results while other results are still loading—at least not without fighting the rerun model.

Serverless deployment

Streamlit runs as a persistent process with WebSocket connections to each user. That means Railway or Render (always-on, always paying) or nothing. The Python serverless story—Mangum wrapping FastAPI, deploying to Vercel—doesn’t apply to Streamlit.

I wanted the frontend on Vercel’s free tier. Splitting the app was inevitable anyway.

I tried adding a modal for trip interest signups. When opened, it would briefly flash the typed text into the sidebar’s destination selector, then navigate back to the homepage instead of rendering as an overlay. Type “MA” into the modal’s email field, and “MA” would appear in the destination multiselect for a split second before the whole page reset.

Streamlit doesn’t have true overlays. Everything lives in the page flow. A “modal” is just conditional rendering that triggers a rerun, which means the rest of the page state can get weird.

The migration

The first commit with FastAPI backend + React frontend landed the same day I decided to migrate. The backend was straightforward—SSE for streaming results, REST endpoints, same business logic. The frontend took the Streamlit concepts and rebuilt them with proper component isolation.

The next day I made six commits in 30 minutes fixing mobile layout. This wasn’t planned. Once I had real CSS control, the gaps were obvious. Streamlit’s responsive handling was “good enough” for basics, but moving away meant rethinking the code structure to handle different screen sizes properly. Things like syncing header and body scroll, responsive column widths, proper drawer navigation on mobile—all trivial in React, all awkward-to-impossible in Streamlit.

What I lost:

  • Single-file simplicity (354 lines for the whole UI)
  • Python-only development
  • Zero-config deploys during iteration

What I gained:

  • Streaming updates (skeleton → partial results → complete)
  • Form inputs that don’t crash the search
  • Real mobile layout
  • Serverless deployment on Vercel’s free tier

The code comparison

Streamlit loading state (from app.py):

progress_bar = st.progress(0, text="starting...")
loading_gif_placeholder = st.empty()

def update_progress(dest_name: str, current: int, total: int, from_cache: bool):
    if not gif_cleared[0]:
        loading_gif_placeholder.empty()
        gif_cleared[0] = True
    progress = current / total if total > 0 else 0
    progress_bar.progress(progress, text=f"{dest_name}")

This looks clean until you realize update_results() is also writing to results_placeholder.container(), and if the user interacts with any widget during this loop, everything might rerun and reset.

React loading state (from LoadingState.tsx):

<div className="h-2 w-64 overflow-hidden rounded-full bg-stone-200">
	<div
		className="animate-indeterminate h-full rounded-full bg-gradient-to-r from-amber-400
                  via-amber-500 to-amber-400"
	/>
</div>

Dumber code, but it doesn’t care what else is happening on the page. The loading state and form inputs exist in completely separate component trees.

Would I use Streamlit again?

For this project, Streamlit was right for v1. It let me test whether the backend logic felt good—whether the ski score formula produced sensible rankings, whether the flight search latency was acceptable, whether the concept resonated at all. That feedback came faster than it would have with a React prototype.

The mistake would have been not recognizing when Streamlit hit its ceiling. The signs:

  1. Workarounds get complex — Half the code in app.py was framework management, not features
  2. The UX you want is architecturally incompatible — Per-destination streaming wasn’t a missing feature, it was impossible
  3. You’re injecting CSS via markdown — When you’re writing st.markdown(unsafe_allow_html=True) to fix spacing, you’ve already left Streamlit’s happy path

Streamlit is great for prototypes, internal tools, and dashboards where “rerun on change” is fine. It doesn’t work for apps with concurrent async operations, complex form interactions, or fine-grained responsive layouts.

The migration took two days. The Streamlit prototype bought me a week of early validation. Net positive.


---
If you enjoyed this post, you can subscribe here to get updates on new content. I don't spam and you can unsubscribe at any time.