Back to the journal

GPT-4o vision can read your whiteboard photo but it silently hallucinates the third column

We ran a small experiment last week: photograph a whiteboard after a planning session, pass it to GPT-4o's vision endpoint, and ask it to extract the sticky notes into structured JSON. For two-column boards with clear handwriting, it worked remarkably well — maybe 90% accuracy on the text itself. The moment we added a third column or had overlapping cards, accuracy dropped hard. The model didn't flag uncertainty. It just invented plausible-sounding task names that weren't on the board. If you're building any workflow that trusts vision model output without a human verification step, this is the failure mode you need to design around.

The deeper issue is that vision models don't distinguish between high-confidence and low-confidence reads the way traditional OCR pipelines sometimes can. A classic Tesseract setup will give you a confidence score per word, so you can flag anything below a threshold for review. Multimodal LLMs return fluent text regardless, which makes the output feel more trustworthy than it is. That fluency is exactly what makes hallucinated content slip through — it reads like a reasonable task description, so nobody questions it until someone checks the original photo.

If you're feeding whiteboard captures, handwritten notes, or scanned documents into an LLM vision pipeline, add a verification layer. Show the extracted data alongside the source image so a person can spot-check before it lands in your project tracker or CRM. The extraction step saves time; skipping verification loses trust. Treat the model as a fast first draft, not a source of truth.

Your staging environment drifted from production three months ago and nobody noticed

Most small teams start with a staging server that mirrors production almost exactly. Then someone upgrades the database engine on prod but not staging, or a new environment variable gets added to the deployment script but never copied across. Three months later a feature works perfectly in staging and breaks on deploy. The team spends a Friday afternoon debugging something that has nothing to do with their code.

The fix is not to maintain staging more carefully — that is a willpower solution and willpower solutions fail. The fix is to make the environment definition a single source of truth. If you use Docker Compose, Terraform, or even a well-structured Ansible playbook, production and staging should both derive from the same configuration files with only the secrets and scale differing. When someone changes the Postgres version or adds a Redis instance, they change it once and both environments inherit the update. This is achievable even on a two-person team; you do not need a platform engineering department.

The sneaky part of environment drift is that it rarely causes an obvious explosion. It causes subtle differences — a query that performs fine on Postgres 15 but hits a planner regression on Postgres 16, a library that links against a slightly different system dependency. These bugs waste disproportionate time because the developer's first assumption is always that their own code is wrong. Audit your staging and production environments this week. Diff the installed packages, runtime versions, and infrastructure config. If anything doesn't match and there is no documented reason for the difference, close the gap before it costs you a weekend.

Your payment fallback flow probably assumes the user has uninterrupted connectivity for the next eight seconds

Most South African payment integrations we audit treat the happy path as the only path. The user taps Pay, a request hits Payfast or Yoco or Peach, a webhook confirms, the order moves to fulfilled. That works beautifully on fibre in Claremont. It falls apart on a 3G connection in Khayelitsha where the round trip drops midway, or when loadshedding knocks out the tower backhaul and the confirmation webhook arrives ninety seconds late to a server that already timed out the session.

The failure mode that catches people is not a declined card — that's handled. It's the ambiguous state: money left the customer's account, but your system never got the confirmation. If you don't have an idempotency key linking the client-side initiation to the server-side webhook, you either double-charge on retry or fulfil nothing at all. We've seen small e-commerce operators manually reconciling these from bank statements every Monday morning because nobody built the pending-payment state into the order lifecycle. A simple polling mechanism that checks transaction status after a configurable delay — thirty seconds, sixty seconds, two minutes — recovers most of these silently without the customer opening a support ticket.

If you're integrating any payment gateway for a South African audience, build three states into your order model from day one: initiated, confirmed, and failed-awaiting-reconciliation. Set up a background job that queries the gateway's status endpoint for any order stuck in initiated beyond your timeout window. That single addition will save you hours of manual recon each month and stop you losing sales to customers who assume the transaction failed and never come back.

Replacing your submit button text with what the user actually gets doubles the reason to click it

Most landing page forms end with a button that says 'Submit'. It is the blandest possible instruction at the highest-friction moment in the entire page. The user has already typed their name, email, maybe a phone number — and the thing asking them to commit offers zero indication of what happens next. Changing that single string to something outcome-specific ('Get my free audit', 'Download the guide', 'Book my slot') reframes the action from giving something away to receiving something back. It sounds trivial, but the button is the last piece of copy a visitor reads before they either convert or bounce.

This works because of a basic UX principle: labels set expectations. A vague button creates a micro-moment of uncertainty — will I get an email, a call, a PDF, a redirect to some other page? Uncertainty at the decision point is conversion poison. When the label mirrors the promise made higher up the page, it closes the loop. If your hero section says 'We'll review your site speed in 48 hours', the button should echo that. Consistency between the offer and the action removes the last reason to hesitate.

Audit every form on your site today. Replace generic button labels with language that names the outcome from the user's perspective, not yours. 'Submit', 'Send', and 'Continue' tell people what to do for you. Flip it: tell them what they get. Then check your analytics over the next fortnight and compare form completion rates — you will likely see the difference from a change that took thirty seconds to ship.

Your code review comments decay into noise after the fourth round of changes

Most teams we work with treat pull request reviews as a single pass: someone leaves comments, the author addresses them, a second pair of eyes approves. That works beautifully when the PR is tight. But when a feature branch survives three or four rounds of revision — scope shifts, rebases, force-pushes — the comment threads become archaeological layers. Half the remarks reference lines that no longer exist. Reviewers lose track of which concerns were resolved and which were quietly abandoned. The review stops being a quality gate and becomes a formality people click through to unblock the merge.

The fix is not more discipline; it is smaller surface area. If a PR has been through more than two review cycles without converging, something upstream is wrong. Either the task was too large, the acceptance criteria shifted mid-flight, or the reviewer and author have an unspoken disagreement about approach that comments alone cannot resolve. A five-minute call or a shared sketch resolves what a twelfth inline comment never will. We have started flagging any PR that hits a third review round as a prompt to split the branch or have a synchronous conversation instead.

Practically, set a soft rule: if a pull request requires a third round of changes, the author and reviewer talk live before any more code is written. This catches scope misunderstandings early, keeps the diff reviewable, and stops your Git history from filling with fixup commits that nobody will ever read again.

Your MVP has three pricing tiers but zero evidence anyone would pay for the middle one

Most founders we work with arrive at three pricing tiers before they've had a single payment conversation with a potential customer. The logic feels sound: a free or cheap option to lower the barrier, a premium option to anchor high, and a middle tier that's supposedly the sweet spot. But that middle tier is almost always invented at a whiteboard, not extracted from what someone actually said they'd pay for. It's a pricing page shaped by SaaS convention rather than demand.

The problem isn't having three tiers — it's that the feature split between them is a guess dressed up as strategy. We see teams agonise over whether to put API access in tier two or tier three, meanwhile they haven't tested whether their first ten users even want API access at all. A better move: launch with one price. One plan. Charge for it. Watch what people ask for that they can't get, and let those requests form your second tier naturally. You'll build tiers that map to genuine willingness to pay, not to a Figma template you borrowed from a competitor.

This doesn't mean you stay on one tier forever. It means your eventual pricing structure is informed by actual usage patterns and actual objections during sales conversations. If three people independently ask for team seats or higher usage limits, that's your tier boundary — and it'll convert far better than one you assumed into existence. Ship one price, listen for the pull, then split.

Lazy-loading your images below the fold while eager-loading the one nobody sees above it

Most sites we audit have added lazy loading to their images — good. But the implementation often defeats itself. We see `loading="lazy"` applied uniformly to every `<img>` tag, including the hero image or banner that sits squarely in the initial viewport. The browser then deprioritises fetching the very asset that dominates your Largest Contentful Paint score. Meanwhile, a decorative background image tucked behind a cookie banner loads eagerly because it was set via CSS `background-image`, which the `loading` attribute doesn't touch.

The fix is unglamorous but precise. Your LCP image — usually the first visually significant element a user sees — needs `loading="eager"` (or simply no `loading` attribute, since eager is the default) plus a `fetchpriority="high"` hint so the browser's preload scanner picks it up early. Everything genuinely below the fold gets `loading="lazy"`. For CSS background images that aren't critical, move them behind an Intersection Observer or toggle them via a class added after initial paint. It takes thirty minutes to get right and it directly affects your Core Web Vitals field data.

Run Lighthouse or PageSpeed Insights and look at the LCP element it identifies. If that element has `loading="lazy"` on it, you're actively slowing down the metric Google uses for ranking. Swap it to eager, add `fetchpriority="high"`, and re-test. One attribute change on one image often shaves 200–400 milliseconds off LCP on a mid-range mobile connection — the kind most of your South African users are on.

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