Back to the journal

Your Flutter app quietly fetches every screen's data on launch because nobody scoped the initial route

We recently audited a Flutter app that took six seconds to reach a usable home screen on a mid-range Android device over mobile data. The culprit wasn't heavy images or slow animations — it was eager initialisation. The app's main.dart kicked off API calls for the user profile, notifications, a favourites list, and two feature flags, all before the first frame painted. Every screen's provider or bloc was wired up at the root level, so the app treated launch like a buffet: fetch everything, just in case.

This pattern creeps in naturally with state management libraries like Riverpod or BLoC when developers register providers globally for convenience. It works fine during development on fast Wi-Fi with a warm backend. But on a Vodacom connection in Khayelitsha, those six parallel API calls compete for bandwidth, and the user stares at a spinner. The fix isn't complicated: defer data fetching to the point where a screen actually needs it. Lazy-provide your blocs so they initialise only when their route is pushed. Use Flutter's `RestorableRouteFuture` or simple navigator observers to trigger fetches on navigation, not on app start.

Audit your `main.dart` and your top-level `MultiProvider` or `MultiBlocProvider`. Count the network calls that fire before the user sees anything interactive. If more than two of those calls serve screens the user hasn't navigated to yet, you're burning data and time for nothing. Move each fetch to the earliest point it's actually required — typically `initState` or a provider's lazy constructor — and watch your time-to-interactive drop.

The project that needs 'just a few small changes' will eat your entire Thursday

Every small studio learns this the hard way. A client emails on Monday with a list of tweaks — move this button, swap that colour, adjust the copy on two screens. You glance at it and think: an hour, maybe two. You don't scope it. You don't log it. You just start. By Thursday afternoon you've untangled a layout dependency you forgot existed, hunted down a font rendering inconsistency across three breakpoints, and discovered the copy change breaks a validation message downstream. The 'small changes' consumed a day and a half of billable time you quoted for something else entirely.

The problem isn't the changes themselves. It's that small requests bypass the part of your brain that estimates properly. When a client sends a formal brief for a new feature, you sit down, think through the edges, and quote accordingly. When they send a casual list of five bullet points, you treat it like admin. You skip the step where you ask: does this touch shared components? Does it need QA across devices? Does it conflict with work already in progress? Those questions feel like overkill for 'small' work, but they're exactly where the lost hours hide.

Before you touch a single change request, spend ten minutes writing a line-by-line estimate — even if it's just for yourself. If the total crosses ninety minutes, tell the client before you start. A quick message like 'these touch more than they look — here's what's involved' protects your week and builds trust. The clients who respect your time are the ones worth keeping.

Your Xero integration breaks every month because you treated chart of accounts codes as constants

Most businesses that connect their internal tools to Xero or Sage treat the chart of accounts like a fixed lookup table. The developer grabs the account codes during initial setup, hardcodes them into the integration mapping, and everything works perfectly until the bookkeeper adds a new expense category or renames one in February. Suddenly invoices land in the wrong account, or the sync fails silently and nobody notices until reconciliation week.

The fix is straightforward but almost nobody bothers: treat your accounting platform's chart of accounts as a live data source, not a config file. Pull the account list periodically, surface a simple mapping UI where someone non-technical can match your internal categories to the current accounts, and validate every transaction against that mapping before you push it. When a code disappears or changes, your system should pause and alert rather than quietly posting to a fallback account. This is maybe two days of extra work during build, but it prevents the quarterly fire drill where your finance person spends a morning hunting misallocated line items.

The broader lesson applies to any integration with a third-party system where the client controls the schema. Inventory categories in your warehouse tool, cost centres in your ERP, even contact tags in your CRM — these all drift over time because the people managing them don't know an integration depends on the exact spelling. Build a thin admin layer that lets a business user re-map when things change, and treat every external identifier as something that could vanish next Tuesday.

GPT-4o hallucinates your function signatures and your tests pass anyway because you mocked everything

We've been using LLM-assisted code generation daily for about eight months now. The pattern that burns us most often isn't wrong logic or bad style — it's confidently invented function signatures. You ask GPT-4o to write a service that calls an existing internal method, and it produces something plausible: correct name, reasonable parameters, sensible return type. Except the real method takes three arguments, not two, and the second one is a config object, not a string. The code reads perfectly. It just doesn't work.

The trap compounds when your tests are also LLM-generated. The model mocks the dependency using the same hallucinated signature it just wrote. Mock in, mock out, green tick. You merge it, deploy it, and it blows up in staging because the actual function throws a type error on the first real call. We hit this three times in one sprint before we changed our workflow. The issue isn't that the model is stupid — it's that it's consistent within its own fiction. If it imagines a function shape, it imagines matching tests.

Our fix is blunt but effective: any LLM-generated code that calls an existing internal API must include at least one integration-level test that imports the real module, no mocks. It adds maybe four minutes per task. We also paste the actual function signature into the prompt context rather than relying on the model to remember our codebase. If you're leaning on AI code generation in a team, audit where your mocks let hallucinated interfaces slip through quietly — that's where the breakage hides.

Your staging environment drifts from production every single week and nobody notices until launch day

Most small teams start with good intentions: a staging server that mirrors production so you can catch problems before they reach customers. But staging environments rot. Someone upgrades the database engine in production during an incident. A new environment variable gets added to the deploy script but not the staging config. The CDN in front of production has caching rules that staging doesn't. Within a few weeks, you're testing against a ghost of what production looked like a month ago, and your confidence in that green staging deploy is misplaced.

We've been bitten by this at least three times in the past year. The worst case was a Postgres version mismatch — staging ran 14, production had been bumped to 16 during a managed-hosting upgrade. A query that relied on a subtly different jsonb casting behaviour passed every test in staging, then threw errors within an hour of going live. The fix took ten minutes. Figuring out why staging hadn't caught it took the rest of the afternoon.

The boring solution works: keep a lightweight checklist of production infrastructure details — database version, runtime version, key environment flags, third-party service modes — and diff it against staging on a fortnightly schedule. Automate what you can with a script that pulls versions from both environments and flags mismatches. If your staging environment isn't worth maintaining accurately, it isn't worth maintaining at all. Treat staging drift as a bug, not background noise, and you'll stop discovering integration problems in front of your users.

Your payment fallback isn't a fallback if it still needs the same uptime your primary gateway does

Most South African e-commerce builds we audit have a "fallback" payment method configured — typically a second gateway or manual EFT option that's supposed to catch transactions when the primary provider wobbles. The problem is that these fallbacks almost always depend on the same infrastructure path. If your Payfast integration fails because your server can't reach their API, your Peach Payments fallback probably can't either, because the bottleneck is your outbound connectivity, not theirs. During load-shedding, when your hosting provider's network degrades or your office-based staging environment goes dark mid-deploy, both gateways fail identically.

A genuine fallback needs to survive on a different failure axis. That means thinking about what actually breaks: DNS resolution, TLS handshake timeouts, database locks from incomplete webhook processing, or your app server running out of memory because queued jobs piled up during a power cycle. One pattern that works well for local small-business apps is generating a static payment reference and QR code client-side, pointing at a dedicated bank account, then reconciling via statement import or bank feed. It's ugly, it's manual, but it works when everything else is down — and for a small merchant doing fifty transactions a day, a two-hour reconciliation delay beats two hours of zero revenue.

Map out the specific infrastructure your primary and secondary gateways share — hosting provider, DNS, outbound IP range, webhook endpoint. If more than two of those overlap, your fallback is decorative. Build at least one payment path that can function with nothing but a static page and a bank account number.

Your form asks for eleven fields because someone in a meeting said 'while we're at it'

We audit a lot of landing pages for clients, and the pattern is remarkably consistent. The original brief called for name, email, and one qualifying question. By the time the form shipped, it had a phone number field (mandatory), a company name, a dropdown for company size, a 'how did you hear about us' select, and sometimes a free-text 'anything else we should know.' Each field was added for a reasonable-sounding reason. Together they form a wall that visitors bounce off without completing.

Every additional form field is a micro-decision you're asking a stranger to make before they've received any value from you. Phone numbers are particularly brutal — in our experience, making a phone field mandatory on a lead-gen form can visibly dent completion rates, because people associate it with being cold-called. The data you collect in those extra fields is often never used, or could be gathered later in the sales conversation when the person actually wants to talk to you. We've seen a client's enquiry form go from roughly 2–3 submissions a week to 8–10 simply by trimming it from nine fields to four and moving the rest into a follow-up email.

The practical takeaway: open your highest-traffic form right now and ask, for each field, whether you genuinely act on that data before the first human reply. If you don't, remove it or make it optional. Collect the minimum needed to start a conversation, then earn the rest. Your marketing team can add a single optional question later if attribution tracking matters — but gate it behind the conversion, not in front of it.

FROM IDEAS TO SOMETHING REAL

Your next chapter.
Let’s build it.

A website, an app, a better way to work. Tell us what you have in mind.

Start a conversation