Most small teams we work with have pull request templates that are either empty or still have GitHub's default placeholder text. The result: a reviewer opens a diff with forty changed files, no summary of intent, no note about what to test, and no indication of which changes are incidental cleanup versus the actual feature. They skim, approve, and move on. The review becomes a rubber stamp disguised as a process.
A useful PR template does not need to be long. Three fields do most of the work: a one-sentence description of what changed and why, a short list of areas to pay attention to during review, and a note on how to verify the change locally. That last one matters more than people think — if your reviewer cannot reproduce the behaviour without a fifteen-minute Slack thread, they will not bother. The template should also nudge the author to flag anything they are uncertain about. A line like 'Anything you want a second opinion on?' surfaces the exact conversations that code review is supposed to produce.
The discipline is not in having the template — it is in filling it in properly every time. We enforce this by making the description a blocking field in our CI pipeline: if the PR body is under thirty characters, the checks fail before any code runs. It takes about ninety seconds to write a decent summary. That ninety seconds saves your reviewer from context-switching into detective mode and saves you from getting shallow approvals on changes that deserved scrutiny.
Charging from day one filters out the feedback that wastes your first six months
We built a booking tool for a Cape Town tour operator last year. The first version was rough — three screens, no admin panel, payments via a manual Yoco link. We wanted to give it away free to the first five operators to 'gather feedback.' The founder pushed back and insisted on charging R200/month from week one. That single decision changed everything about what we heard from users.
Free users give you feature wishlists. They tell you it would be nice to have calendar sync, a multi-language toggle, automated WhatsApp confirmations. Paying users tell you what is actually broken in their day. The operator paying R200 told us her staff kept double-booking the 2pm slot because the time picker defaulted to AM. She told us the confirmation email landed in Gmail's promotions tab so guests never saw it. Every piece of feedback pointed at something that blocked a booking from completing — because she had money on the line and needed the tool to earn it back.
This does not mean your price has to be final, or even sustainable. It means the act of paying creates a filter. The person who will not pay R200 for something that saves them two hours a day is telling you they do not have the problem you think they have. Charge something — anything above zero — before your first feedback round. The responses you get back will be about workflow, not wishlists, and that is the difference between validating a product and collecting opinions.
A single unoptimised hero image is quietly eating half your Largest Contentful Paint budget
Most performance conversations jump straight to JavaScript bundles and third-party scripts, but on the marketing sites and e-commerce storefronts we build, the biggest LCP offender is almost always a hero image that nobody thought to question. It shipped as a 2.4 MB PNG exported from Figma, gets served at its original 3000-pixel width to a phone on a 360-pixel viewport, and has no explicit width or height attributes so the browser can't reserve space for it. That one asset regularly accounts for 1.5–2 seconds of load time on a mid-range Android device over a decent South African mobile connection.
The fix is boring but effective. Convert to WebP or AVIF using a build step or an image CDN — we use a combination of Sharp in the build pipeline and Cloudflare's Polish for anything user-uploaded. Add a srcset with sensible breakpoints (640, 1024, 1536 pixels covers most layouts) and set fetchpriority="high" on the hero img element so the browser doesn't waste time discovering it late. If the image sits below a CSS background-image rule instead of an img tag, the browser won't start fetching it until the stylesheet is parsed, which adds another round trip you don't need.
Run Lighthouse on your landing page right now, expand the LCP element audit, and check what it flags. If it's an image wider than the viewport or heavier than 200 KB after compression, you've found free performance. Shaving even 500 milliseconds off LCP noticeably improves bounce rates, especially on mobile — and it's a fifteen-minute fix, not a rewrite.
Flutter's platform channel is the escape hatch nobody plans for until the Bluetooth library stops working
Cross-platform frameworks sell you on one codebase for everything, and for screens full of lists and forms that promise mostly holds. Where it breaks down is hardware. We had a Flutter app last month that needed to pair with a Bluetooth Low Energy peripheral — a card reader used by field agents in the Eastern Cape. The community BLE plugin worked fine on Android 13 but silently failed to discover devices on certain Samsung handsets running Android 12. No error, no callback, just nothing. Three days of debugging later, the fix required writing a platform channel to call the native Android Bluetooth API directly.
This is the part of cross-platform development that rarely makes the pitch deck. Platform channels let your Dart code call native Swift or Kotlin, and they work well, but the moment you drop into them you need a developer who can actually read and debug native code. If your entire team chose Flutter specifically to avoid learning Kotlin, you now have a gap. The plugin ecosystem papers over this most of the time, but hardware integrations, background processing, and OS-specific notification handling are exactly where the paper tears.
Before you commit a Flutter project that touches device hardware — BLE, NFC, specific camera APIs, biometric edge cases — prototype the native integration first, not last. Build a small platform channel spike in week one. If it works cleanly, Flutter remains a great choice. If it needs workarounds, you know early enough to either hire native experience or scope the project differently. The worst outcome is discovering this in UAT with a client watching.
The scope document that says 'and everything else needed' is a blank cheque you just handed your client
Every fixed-price proposal has a danger zone, and it almost always lives in the catch-all line. You know the one: 'final polish and any remaining items to complete the project.' It feels professional and thorough when you write it. What it actually does is create an unbounded obligation that your client will interpret far more generously than you intended. Three months in, 'remaining items' has grown to include an admin dashboard, PDF exports, and a notification system nobody mentioned during discovery.
The fix is painfully simple but requires discipline: never describe scope by category, describe it by output. Instead of 'user management module,' list the exact screens and behaviours — a registration form with email verification, a profile edit page with avatar upload, a password reset flow. If it isn't on the list, it's a change request. This feels tedious during proposal writing, but it saves you from the conversation where a client says 'I assumed user management included role-based permissions and audit logging' and technically your document doesn't disagree.
Some studios worry that granular scope documents scare clients off. In our experience the opposite is true. Clients who see a specific list trust that you've actually thought about their project. The vague proposal is the one that looks like a template. When a new feature request comes in mid-build, you're not having an argument — you're pointing at a line that doesn't exist and quoting for an addition. That's a healthier conversation for everyone.
That inventory spreadsheet everyone emails around has three different versions of the truth by Friday
We see this pattern constantly: a Google Sheet tracks stock, someone downloads it to work offline, another person copies a tab to build a quote, and by Friday the warehouse thinks there are forty units while sales has already promised fifty-three. The spreadsheet was never designed to be a system — it just quietly became one. The moment two people need to write to the same data at the same time, you have a concurrency problem dressed up as a CSV.
The fix is not necessarily a massive ERP rollout. Often the highest-value first step is pulling that one spreadsheet into a small database with a simple front end, then exposing it via an API so your Xero integration and your WhatsApp order bot read from the same source. We typically use Supabase or a lightweight PostgreSQL instance behind a Next.js admin panel. The migration itself is a weekend project; the discipline of deciding who owns each field is the part that takes real thought.
Before you build anything, sit with the person who actually maintains the spreadsheet and map every column to a question: who updates this, when, and what breaks if it is wrong? Half the columns will turn out to be dead weight. The other half will reveal business rules nobody has written down. Capture those rules in validation logic, not in a cell comment that reads 'DO NOT DELETE — ask Thandi'. That is your starting point for a system that tells one version of the truth, every day of the week.
GPT-4o's structured output mode saves you the regex nightmare of parsing freeform AI responses
If you've ever wired an LLM into a production workflow, you know the pain: you ask for JSON, you get JSON wrapped in markdown fences, or a chatty preamble before the actual object, or a field name that's slightly different from what your code expects. So you write regex to strip the fences, a try-catch around the parse, a fallback for when the model decides to be creative with your schema. Half your integration code ends up being defensive string surgery rather than actual business logic.
OpenAI's structured output mode — where you pass a JSON schema and the model is constrained to produce valid output matching it — mostly eliminates this. We've been using it on a client project that classifies incoming support emails into category, urgency, and suggested-action fields. Before structured outputs, roughly one in fifteen responses needed a retry because of malformed JSON or hallucinated extra fields. After switching, parse failures dropped to effectively zero. The trade-off is a small latency bump on the first request while the schema compiles, and you need to be precise about your schema definitions — optional fields, enums, and nested objects all need to be explicitly declared or the model will reject the call.
The practical takeaway: if you're calling any LLM and parsing the result programmatically, define your expected output as a strict schema before writing a single line of parsing code. It moves validation from your side to the model's side, which means fewer silent failures in production and far less defensive code to maintain. Anthropic and other providers are heading in similar directions, so the pattern is worth adopting regardless of which API you use.