Back to the journal

Designing checkout flows for the customer who just lost power mid-payment

In South Africa, a payment session that takes forty seconds is a gamble. Load-shedding knocks out home routers, mobile signal degrades when towers switch to battery backup, and customers tap 'Pay' not knowing if their connection will survive the round trip. Most checkout implementations treat a dropped connection as an edge case. Here, it is a Tuesday. If your flow cannot gracefully handle an interrupted payment, you are leaking revenue every time Eskom drops a stage.

The fix is not exotic. First, generate a unique idempotency key before the customer taps the pay button, not after. This means if they reconnect and retry, your backend and your payment gateway (Paystack, Yoco, Peach Payments — all support idempotency keys) will recognise the duplicate and not double-charge. Second, persist the cart and payment intent on the server side, not just in the browser session. A customer who reopens your site on mobile data after their fibre dies should land back on a 'complete your payment' screen, not an empty cart. Third, use webhooks as the source of truth for payment status rather than relying on the redirect back to your site — that redirect is exactly what dies when connectivity drops.

The pattern is simple: assume the connection will break between the moment the customer authorises and the moment your server confirms. Build every step around that assumption and you stop treating South African infrastructure as a bug. Practically, audit your current checkout — try killing your network connection right after tapping pay, and see what your customer actually experiences. That ten-minute test will tell you more than any analytics dashboard.

Your Xero integration is probably a ticking time bomb of stale webhook subscriptions

Most businesses that connect Xero to their internal systems treat the integration as a set-and-forget affair. The invoices flow, the bank feeds reconcile, everyone moves on. But Xero webhook subscriptions silently expire or lose their delivery target more often than people realise — a changed server URL, a rotated API key, a Cloudflare rule that starts blocking the callback. The data stops syncing, and nobody notices until the month-end recon blows up or a customer queries an invoice that was supposedly paid weeks ago.

We've started building a dead-simple heartbeat check into every Xero integration we ship. A lightweight cron job pings the webhook endpoint hourly, confirms the subscription is still active via the Xero API, and posts a Slack alert (or WhatsApp message, because this is South Africa) the moment something looks off. It takes maybe half a day to implement and has already saved two of our clients from discovering broken syncs only at VAT submission time. The pattern works just as well for Sage or any accounting platform that relies on event-driven callbacks.

The broader lesson is that any integration touching financial data needs its own monitoring layer, separate from your application health checks. Your app can be perfectly healthy while the pipe between it and your accounting system has been dry for a fortnight. If you run any webhook-based accounting integration, add a recurring verification call against the provider's subscription API and alert on silence, not just on errors. Broken pipes that fail quietly are far more dangerous than ones that throw exceptions.

That lazy-loaded hero image is hurting your Largest Contentful Paint more than you think

Most developers know that lazy loading images improves page performance. The problem is when that instinct gets applied blindly — including to the hero image sitting right at the top of your landing page. We see this surprisingly often in audits: a above-the-fold banner or product shot carrying `loading="lazy"`, which tells the browser to deprioritise the very element that defines your Largest Contentful Paint score. The browser waits, the viewport sits empty for an extra beat, and your LCP tanks by hundreds of milliseconds.

The fix is straightforward but easy to forget in component-driven frameworks. If you're using a reusable `<Image>` component in Next.js, Nuxt, or a custom design system, lazy loading is often the default. That's sensible for images further down the page, but your hero needs an explicit override: `loading="eager"`, combined with a `fetchpriority="high"` attribute. In Next.js, the `priority` prop on `next/image` handles both. Without it, the framework's helpful defaults actively work against you on the most important render metric Google measures.

Run Lighthouse or PageSpeed Insights on your highest-traffic landing page today. If the LCP element is an image, check whether it has `loading="lazy"` set — either directly or inherited from a component default. Flipping that single attribute to eager and adding fetch priority is a five-minute change that can shave 200–500ms off your LCP. For an e-commerce or lead-gen site where that page is your front door, those milliseconds directly affect bounce rate and ad quality scores.

AI-assisted database migrations will cheerfully destroy your production data

We recently watched an AI coding assistant generate a database migration that looked perfectly reasonable: rename a column, update a foreign key, add an index. Clean syntax, sensible ordering. The problem was that it had no awareness of the 2.3 million rows already living in that table, or the three background jobs that would break the moment the column name changed. It generated a technically valid migration with operationally catastrophic consequences. This is a class of problem that doesn't get enough attention.

The issue is that schema changes sit at the intersection of code logic and live system state, and current AI tools only see the code half. They can read your model definitions and your migration history, but they have zero visibility into row counts, active queries, lock contention, or dependent services. They treat migrations like any other code generation task — pattern-match against training data, produce syntactically correct output. But a migration isn't just code. It's an instruction you're giving to a running system under load, and the consequences of getting it wrong are not a failing test but lost or corrupted data.

If you're using AI to draft migrations, treat every output as a first sketch that needs operational review. Check whether the migration is reversible. Ask what happens to existing data during the transition. Run it against a production-sized dataset copy before it goes anywhere near the real thing. The useful framing is this: AI can write the SQL, but it cannot reason about what that SQL will do to your Thursday afternoon.

One misconfigured S3 bucket policy can quietly make your client uploads public for months

Most small teams we talk to store user-uploaded files in S3 or a compatible object store like Backblaze B2 or MinIO. The setup usually happens once during project scaffolding, gets copy-pasted from a tutorial, and never receives a second look. The problem is that a surprising number of those tutorials grant public-read access at the bucket level to avoid dealing with pre-signed URLs or CloudFront distributions. If your app serves any kind of sensitive document — ID uploads, invoices, contracts, medical files — that shortcut means every object is accessible to anyone who can guess or enumerate the key.

What makes this particularly insidious is that nothing breaks. Your app works perfectly. Files upload, files download, users are happy. There is no error log screaming at you. The exposure only surfaces when someone runs a scanner, a pen test flags it, or — worst case — a client's customer finds their documents indexed by a search engine. We have seen this twice in audits of codebases inherited from other agencies, both times with POPIA-relevant personal data sitting wide open.

The fix is straightforward: set your bucket policy to private, generate short-lived pre-signed URLs for downloads, and add a CI step that fails the build if any bucket policy contains a public-read grant. AWS CLI's `aws s3api get-bucket-policy-status` gives you a one-liner check. If you are on Terraform or Pulumi, add a policy assertion in your infrastructure tests. Fifteen minutes of guardrail work now saves you from a compliance incident that no amount of apology emails can undo.

Most South African SaaS apps quietly break when a user's mobile data runs out mid-session

We build and test our web apps on fibre connections in Cape Town offices, then wonder why support tickets spike from users in Limpopo or the Eastern Cape. The issue isn't load shedding this time — it's the unpredictable, metered mobile data that most South African internet users actually rely on. When a prepaid data bundle expires mid-session, the connection doesn't always fail cleanly. Fetch requests hang, partial JSON responses corrupt local state, and your carefully designed SPA renders a blank white screen with no explanation.

The fix isn't exotic. It starts with treating every API call as if the network will vanish halfway through. That means setting aggressive fetch timeouts (not the browser default of several minutes), caching the last known good state in IndexedDB or localStorage, and showing meaningful offline indicators instead of infinite spinners. Service workers help, but only if you actually cache the app shell and critical data — most implementations we audit cache static assets and nothing else. For form-heavy workflows like onboarding or applications, persisting draft state locally before submission is the difference between a completed signup and an abandoned one.

If you're building for the South African market — and especially if you're targeting consumers or small businesses outside major metros — run your QA on a throttled 2G connection with random disconnects. Chrome DevTools lets you simulate this in seconds. The goal isn't graceful degradation as a nice-to-have; it's recognising that for a large share of your users, the degraded state is the normal state.

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