DEV Community

Cover image for The Maintenance Nightmare of Low-Code Apps (And What a Native Rewrite Actually Costs)
Luis Portal
Luis Portal

Posted on

The Maintenance Nightmare of Low-Code Apps (And What a Native Rewrite Actually Costs)

TL;DR

  • Low-code gets you to market fast. The bill usually arrives when you need to change something, not when you launch.
  • Migrating a FF (low-code) app to native Flutter took 80+ commits over a few weeks. None of them shipped a feature.
  • I wasn't a no-code person. I wrote a lot of custom Dart inside FlutterFlow. The visual layer still owned the architecture.
  • With tools like Cursor, the speed advantage of low-code is much smaller than it was in 2022. The technical debt is not.

I’ve spent years building complex mobile architectures. Like many in the industry, I embraced visual builders like FlutterFlow when they peaked, appreciating what they taught us about component reusability and shipping fast. I have a lot of respect for these tools.

But technology moves on.

This past month, I migrated a live production app from FlutterFlow to native Flutter, using Cursor as my main IDE. The app had real users, AI features, and payments. On the surface, everything worked. Screens loaded, notifications fired, users logged in.

Then I looked at the code underneath. The app hadn't been built. It had been assembled. That difference doesn't matter when you're launching. It matters a lot when you're growing.

This isn't an argument against low-code. It's about what happens after your prototype succeeds, and why nobody talks about that part until it's already expensive.


The hybrid developer trap

Before we get into the house metaphor, I want to address something that gets lost in most low-code debates.

I wasn't avoiding code. Custom Functions, Custom Actions, Custom Widgets. I lived in those. A lot of business logic was hand-written Dart.

The "skill issue" argument goes like this: the platform is fine, the developer just didn't structure things properly. FlutterFlow gives you escape hatches. A disciplined developer can build decent architecture.

There's truth in that. But here's what I ran into over and over: even when your logic is custom, it still plugs into generated scaffolding you don't control. State still defaults to FFAppState. Auth rules still scatter across visual action flows on individual screens. You can write clean functions, but you can't grep your way through the app the way you would in a normal codebase.

You're half developer, half visual editor operator. And when the project gets complex, the visual layer wins.


What "built fast" looks like from the inside

You hire a company that builds houses in 30 days. They deliver on time. The house looks great. You move in.

Six months later, you want to add a second bathroom. The contractor opens the wall and finds there are no shutoff valves (fix one pipe, you shut down water to the whole house), the electrical panel is one breaker for every room, and some walls are load-bearing in places that make no sense.

The house wasn't built wrong. It was built fast. Fast construction makes trade-offs that only show up when you try to change something.


Where those costs show up

Your app gets slower and nobody knows why

FlutterFlow's built-in state system (FFAppState) is a global singleton, available everywhere in the app. It's also the path of least resistance in the visual editor. In most projects I've seen, it becomes the default home for screen-specific flags, temporary values, things that should have lived and died on a single page.

State accumulates. After 30 minutes of normal use, the app is carrying data from flows the user finished ten minutes ago. It starts to feel heavy. Users can't say why. They don't complain. They just open the app less.

In the rewrite, we went with Riverpod. Each feature has its own provider, scoped to what it actually needs. When a screen is gone, its state is gone.

// Before: global singleton, easy to set, hard to trace
FFAppState().checkoutStep = 2;

// After: scoped to the feature, disposed when done
@riverpod
class CheckoutFlow extends _$CheckoutFlow {
  @override
  CheckoutState build() => const CheckoutState();
}
Enter fullscreen mode Exit fullscreen mode

The app felt snappier after the migration. We didn't rewrite any algorithm. We stopped dragging dead weight across every navigation event.

Refactoring becomes archaeology

In native code, changing how your auth flow works means updating a service class and its usages. Grep the codebase, update three files, done.

In my FlutterFlow project, every screen that touched auth had its own conditional actions configured visually. Changing a rule meant opening each screen's action flow, digging through nested conditionals, checking each one by hand. No single source of truth. Logic spread across visual nodes with no way to search or refactor it as code.

That's not really a platform bug. It's what visual programming at scale tends to produce. The tools that make the first version fast are the same tools that make changes slow and risky.

Testing feels like fighting the platform

Low-code architecture fuses business logic with the interface. Even when platforms add testing features, it often feels bolted on. To verify a complex calculation, we relied on visual test builders or UI-driven tests that simulated taps.

That's not unit testing. That's automating a user with extra steps. Slow, brittle, and easy to miss edge cases.

After migration, that same calculation is a standalone function:

double calculateDiscount({required double subtotal, required int itemCount}) {
  if (itemCount >= 5) return subtotal * 0.15;
  if (itemCount >= 3) return subtotal * 0.10;
  return 0;
}

// test it in milliseconds, no UI required
test('applies tiered discount', () {
  expect(calculateDiscount(subtotal: 100, itemCount: 5), 15);
});
Enter fullscreen mode Exit fullscreen mode

That's the difference between "we think it works" and "we know it works."

You ship dead weight to every user

Visual builders need to support every possible use case. In projects I've worked on, the generated codebase often pulled in dependencies for features we never used. Maps libraries, charting packages, animation tooling. Stuff we didn't ask for, bundled under the hood.

App size went up. Build times got longer. Package updates turned into dependency roulette.

When we rewrote natively, we kept only what the business logic actually needed. Builds got faster. We finally controlled what landed on users' devices.


What the migration actually cost

I'll be direct: 80+ commits across every layer of the app, over a few weeks. Not one of them shipped a feature. All of them fixed things that were invisible from the outside but making the app slower, harder to maintain, and more expensive to change every month.

Renovating a house while people are still living in it. Replace the plumbing system by system, pour a proper foundation under what's already there, keep the lights on.

Every month you delay, the cost goes up. Every feature you build on the old architecture adds another connection to the old plumbing.

We didn't try to refactor the exported code. That path looked like a trap. We treated the visual app like a Figma file and started fresh.


The new MVP math: AI vs. low-code

For years, the defense of low-code was ROI: "Sure, the code is messy, but we got to market in 30 days instead of 6 months."

That argument made sense in 2022. It's harder to make today.

When I rewrote this app with an AI IDE, the pace felt comparable to configuring complex flows in FlutterFlow. Maybe faster, because I wasn't hunting through property panels. I was writing.

The difference: at the end of those weeks, I owned the architecture. Testable code. No generated files I was afraid to touch. No black box that breaks when business logic gets complex.

The speed-to-market advantage of low-code has shrunk. The technical debt hasn't.

Cursor helped with boilerplate and repetitive wiring. The architecture still needed someone who understood the product. AI sped things up. It didn't replace the thinking.


The clock is already running

Low-code tools optimize for getting something on screen fast. Native Flutter optimizes for keeping it stable, testable, and maintainable long after launch. Different goals. The gap between them gets wider as your product grows.

If your app started on low-code and succeeded enough to outgrow it, that's not a mistake. The prototype did what it was supposed to do.

The question is what comes next, and whether you pick the timing or the codebase picks it for you.

If you've been through something similar, or you're in the middle of this decision right now, I'd genuinely like to hear how it's going.

Top comments (0)