Most sites we audit load two or three custom typefaces — a heading font, a body font, sometimes a monospace accent. Each weight and style is a separate file, and browsers won't render text until the font has downloaded or a swap timeout kicks in. On a decent Cape Town fibre line you barely notice. On a mobile connection in Limpopo or the Eastern Cape, your carefully chosen sans-serif creates a blank white flash that lasts long enough for visitors to bounce. Google's Largest Contentful Paint (LCP) metric punishes this directly, and it drags your search ranking down with it.
The fix isn't to abandon custom fonts — it's to be surgical about loading them. First, subset your font files to only the character ranges you actually use. A full Google Fonts download of Inter includes Cyrillic, Greek, and Vietnamese glyphs your site will never display. Tools like `glyphhanger` or Fontsquirrel's generator can cut file sizes by 60–70%. Second, use `font-display: swap` in your `@font-face` declarations so the browser shows a system fallback immediately and swaps in the custom font once it arrives. Third, preload only the single most critical font file with `<link rel="preload" as="font" crossorigin>` — preloading everything defeats the purpose.
Pick a system font stack for body copy and reserve your custom typeface for headings alone. One font file instead of four makes a measurable difference to LCP. Run a Lighthouse audit before and after — the number tells you whether your typography choices are a design asset or a performance liability.
Your Android back button breaks your Flutter app's checkout flow and users just leave
Flutter gives you a single codebase for iOS and Android, but it also gives you a false sense of navigation parity. On iOS, users swipe to go back and the gesture is predictable — it always pops the current route. On Android, the system back button does whatever your Navigator stack dictates, and if you haven't thought carefully about that stack during a multi-step flow like checkout or onboarding, the button can dump users two screens back, skip a confirmation step, or land them on a blank intermediary route that was only meant to exist for a half-second transition. We've debugged this exact scenario in three separate client projects this year.
The root issue is that Flutter's default Navigator.pop behaviour doesn't distinguish between a deliberate back tap and Android's system-level back gesture. When you push replacement routes, use nested navigators, or rely on go_router's redirect logic, the back button's destination becomes ambiguous. The fix is unglamorous: you need to wrap critical flows in a WillPopScope (or PopScope in newer Flutter versions) and explicitly define what 'back' means at every step. For checkout specifically, going back from a payment confirmation screen should return to cart review, never to product browse. That mapping needs to be intentional and tested on a physical Android device, not just the iOS simulator most teams default to.
Before you ship any multi-step flow in Flutter, walk through it on Android using only the system back button. Map every screen transition and confirm that each back press lands where a user would expect. If it doesn't, lock it down with PopScope and handle the intent yourself. Five minutes of manual testing saves you an abandoned cart rate you'll never see in your crash logs.
Clients who approve wireframes still panic when they see the real build because wireframes don't communicate what clients think they communicate
We send wireframes early in every project. The client signs off. Two weeks later we deliver the first working build and the call goes sideways: 'This isn't what I expected.' The wireframe did its job from a design perspective — layout, hierarchy, interaction flow — but the client was looking at it like a rough sketch of the final product. They filled in the blanks with their own assumptions about colour, copy density, animation, and visual polish. The sign-off was real; the shared understanding was not.
The gap isn't about bad clients or bad wireframes. It's a translation problem. Most clients outside the tech industry have never developed the habit of reading a wireframe as a structural document. They see boxes and placeholder text and project a finished thing onto it, the same way you might hear a rough demo of a song and assume the final version will sound exactly like that but louder. We've started pairing wireframes with a short recorded walkthrough where we narrate what is decided (navigation, content blocks, user flow) and what is not decided yet (visual design, motion, final copy). That single distinction — decided vs. not yet decided — has cut our 'this isn't what I expected' moments by roughly half.
If you scope fixed-price projects, this matters even more, because a misaligned expectation at wireframe stage becomes a scope dispute at delivery. Practical takeaway: never send a wireframe without a short written or recorded note that explicitly lists what the wireframe is not showing. Make the client confirm they understand the boundaries of the document, not just the contents of it.
Your Xero integration emails you when an invoice is created but nobody built the rule for when one gets deleted
Most businesses that connect Xero or Sage to their internal tools set up the happy path first: new invoice created, push it to the dashboard, maybe fire off a WhatsApp confirmation. That flow gets tested, demoed, and signed off. But accounting data doesn't only move forward. Invoices get voided. Credit notes appear. Line items change after the fact. If your integration only listens for creation events, your downstream data drifts out of sync within weeks — and the person who notices is usually your bookkeeper at month-end, not your software.
The pattern we see repeatedly is that teams treat accounting integrations like one-directional pipes. Data flows out of Xero into a CRM or inventory sheet, but nobody maps what happens when the source record mutates. Xero's webhooks will tell you a contact or invoice has changed, but they don't tell you what changed — you have to fetch the object and diff it yourself. If you skip that work, you end up with phantom line items in your reports, or worse, stock counts that reflect orders your finance team already reversed.
Before you ship any accounting integration, write down every destructive or mutating event the accounting system supports — voids, deletions, credit notes, status changes — and decide explicitly what your system does for each one. Even if the answer is 'log it and alert someone on Slack,' that's better than silent divergence. The goal isn't to automate every edge case on day one; it's to make sure no change in your books goes unnoticed by your software.
GPT-4o will happily write your database migration and silently drop a column you still need
We had an intern use ChatGPT to generate a Rails migration last month. The prompt was reasonable: 'consolidate these three address fields into a single jsonb column.' The generated migration created the new column, wrote a reversible data transfer, and dropped the old columns. It looked clean. It passed review. What nobody caught until staging was that a background job still referenced one of the dropped columns directly, and the migration had no guard clause or dependency check. The AI had no way to know about that job because it wasn't in the prompt context.
This is the specific kind of failure that makes AI-assisted database work dangerous in ways that AI-assisted front-end work usually isn't. A wrong CSS class is visible in seconds. A dropped column might not surface until a nightly Sidekiq job runs and throws a NoMethodError at 2 a.m. Large language models generate structurally correct SQL and migration syntax remarkably well, which is exactly what makes the output feel trustworthy. The danger isn't bad syntax — it's missing context. The model doesn't see your codebase's dependency graph unless you feed it in, and even then it can't reliably trace every reference.
If you're using AI to draft migrations, treat every destructive operation — column drops, table renames, type changes — as a mandatory manual review checkpoint. Grep your codebase for every reference to the affected column before you run the migration, even on dev. The five minutes that takes is cheaper than restoring a production backup at midnight.
Your staging environment drifted from production three months ago and nobody noticed until the deploy failed
Small teams almost always start with good intentions: a staging server that mirrors production so you can catch problems before they reach users. But staging environments rot. Someone upgrades the database engine in production during an incident but forgets to touch staging. A new environment variable gets added to the deploy script for prod but never makes it across. The Node version creeps up on one side. Three months later your staging environment is a fiction — it tells you things work when they actually don't, or tells you things are broken when they're fine. Either way, it's stopped doing its job.
The worst part isn't the drift itself. It's the false confidence. A developer runs the full test suite against staging, everything passes, the team ships with conviction, and then a migration fails in production because the Postgres version differs by a minor release and a function behaves differently. Now you're debugging at 9pm with users watching. The staging environment didn't just fail to help — it actively lied to you.
If your team is small enough that nobody owns infrastructure full-time, automate the comparison. A simple script that checks OS version, runtime versions, key packages, and environment variable names across both environments — run weekly, posted to Slack — catches drift before it bites. Infrastructure-as-code tools like Terraform or Pulumi help, but even a fifteen-line bash script beats trusting that everyone remembers to update both sides. Treat staging parity as a recurring chore, not a one-time setup task.
Your payment fallback flow probably assumes the user has stable data and that assumption fails in most of South Africa
Most South African payment integrations treat connectivity as binary: either the user is online and can pay, or they're offline and can't. The messy middle — where a user on a 3G connection initiates a Payfast or Yoco redirect, the page half-loads, the callback times out, and neither your system nor the user knows whether the money actually moved — is where real transactions get lost. We've debugged this exact scenario on three separate client projects in the past year, and it's almost never caught during QA because the team tests on fibre.
The fix isn't complicated, but it requires you to design for uncertainty as a default state. Store a pending transaction record before you redirect. Use server-side webhook confirmation as your source of truth, not the client-side return URL. Build a reconciliation check that runs every few minutes and resolves orphaned payments — crediting the user or flagging for manual review. Most importantly, show the user a clear status screen that doesn't just spin forever: tell them their payment is being confirmed, give them a reference number, and let them check back. That tiny piece of UX prevents a support ticket and a lost customer.
If you're building e-commerce or any payment flow targeting South African users outside major metros, test your entire checkout on throttled connections with intermittent drops. Chrome DevTools can simulate this. Kill the connection mid-redirect and see what your system does. If the answer is 'nothing graceful,' you have a gap that's silently costing conversions right now.