#debugging #software development #web development #react #typescript #frontend development #bug fixing #race conditions #api design #error handling #ux #dark mode #responsive design #code quality #junior developers #engineering best practices #cross-platform development #javascript
A Beginner's Field Guide to the Bugs Every App Eventually Has
2026-08-06 · 28 min read

1. Trusting a calculated position without checking its limits
What it looks like: You pinch-zoom into an image, and instead of the spot you zoomed into staying under your fingers, the image drifts and slides partway off the screen.
Why it breaks: Zooming into a point is really two instructions bundled together: "keep this spot fixed" and "scale everything around it by this much." Both instructions, individually, are correct. What's missing is a check afterward: does the result of applying both instructions ever push the image somewhere it shouldn't be allowed to go — off the edge of its own frame? The code computed a mathematically correct new position and simply trusted it, the way you'd trust a GPS direction without glancing at the map to see if it just told you to drive into a lake.
The fix, in plain terms: Any time you calculate a new position, size, or value from a formula, treat the result as a proposal, not a final answer. Add a second step that clamps it — recomputes the valid boundaries and pulls the value back inside them if it strayed outside. This is true whether you're positioning an image, scrolling a list, or setting a percentage that must stay between 0 and 100.
How to catch it early: Never test a resize/zoom/drag interaction from the exact center of the screen — that's the one position where boundary bugs are mathematically incapable of showing up. Deliberately test from a corner, an edge, or the smallest and largest extremes.
2. When two systems both think they're in control
What it looks like: Mid-gesture — while you're still actively zooming or scrolling — the content visibly jerks or drifts on its own, as if an invisible second hand is nudging it.
Why it breaks: Browsers have built-in automatic behavior meant to be helpful: if content on the page changes size while you're scrolled partway down, the browser will quietly adjust your scroll position so you don't lose your place — similar to autocorrect silently fixing a word while you're still mid-sentence. That's fine when nothing else is touching scroll position. But here, the app's own zoom code was also actively repositioning the view at the same moment. Two independent systems, each with no awareness the other exists, both trying to be the one in charge.
The fix, in plain terms: The moment your code takes manual control of something a platform normally manages automatically — scroll position, keyboard focus, layout — you can no longer assume the automatic system will politely back off. You have to either explicitly disable it or actively re-assert your own version, continuously, for as long as the manual control lasts.
How to catch it early: Test the interaction while it's happening, frame by frame, not just the end state after you let go. A large category of interaction bugs exists exclusively mid-motion; "does it look fine once it settles" isn't actually testing the bug.
3. Measurements that go stale
What it looks like: You rotate a phone from portrait to landscape while looking at something, and the layout suddenly misaligns or clips content — even though nothing about the content itself changed.
Why it breaks: Early in the component's life, the code measured the available screen space exactly once and stored that number to reuse later. But rotating a phone doesn't just change the height — it changes the width too, and nothing ever re-measured. It's the equivalent of photographing a room's dimensions on move-in day, then designing every piece of furniture around that one photo forever, even after the room's actual shape changes.
The fix, in plain terms: Any cached measurement of your environment has an invisible expiration condition attached to it, whether you wrote one down or not. The fix isn't "stop caching" — caching is often necessary for performance. It's naming the specific events that should invalidate the cache (a resize, a rotation, a sidebar collapsing) and re-measuring exactly when those events fire, instead of hoping the number stays true forever.
How to catch it early: For every value you read once and store away, ask: "what real-world event could make this number wrong later?" If you can name one, that event needs to trigger a refresh.
4. String assembly bugs — the invisible character that breaks everything
What it looks like: API calls silently fail, or worse, quietly return the wrong data — and the URL involved, if you print it out, looks almost right except for a double slash sitting where a single one should be (https://api.example.com//Community/Messages).
Why it breaks: The base URL was being read from an environment variable (a configuration value set outside the code, e.g. in a .env file — think of it as a sticky note taped to the server saying "here's the address to use"), and someone had configured it with a trailing slash. Every endpoint path in the code also started with a leading slash. Individually, both are reasonable choices. Glued together with simple string concatenation, they produced two slashes instead of one — and to a server, that isn't cosmetic; a double slash can be treated as a different, and often invalid, path entirely.
The fix, in plain terms: Whenever you assemble a piece of text — a URL, a file path, a SQL fragment — from two independently sourced pieces, don't just concatenate blindly. Normalize the seam: strip trailing separators from one side, or leading separators from the other, before joining them, so the result is correct regardless of how each piece happened to be formatted.
How to catch it early: Log or print the final assembled value during development, not just the two ingredients that went into it. A bug in the seam is invisible if you only ever inspect the pieces separately.
5. Doing something twice by accident
What it looks like: A user taps "Submit" — and because the button felt unresponsive for a fraction of a second, taps it again. The action fires twice: two submissions, a duplicate skip, or a double charge.
Why it breaks: Network requests aren't instant. Between the tap and the server's response, there's a window — often just a few hundred milliseconds — where, from the user's perspective, absolutely nothing has happened yet, so tapping again feels completely reasonable. But the code had no memory of "a request is already in flight," so the second tap was treated as a brand-new, independent action.
The fix, in plain terms: Any action that shouldn't be allowed to overlap with itself needs a lock — a simple flag that says "busy, ignore further attempts" — set the instant the action starts and cleared once it's safely done (in this codebase, a short cooldown timer after the click, and separately, a "mutex"-protected flag around the authentication token refresh logic, so two nearly-simultaneous requests can't both decide the login token needs refreshing at once and trigger two refreshes in parallel). The general principle is the same one behind a bathroom door lock: the lock isn't there to be polite, it's there because two people literally cannot occupy the same space safely at once.
How to catch it early: Physically double-tap or double-click every button that triggers a network request, on purpose, during testing. If tapping twice does the action twice, that's the bug — and it's one of the easiest to reproduce on demand, precisely because it never needs special conditions, just impatience.
6. Empty ≠ Loading
What it looks like: For a brief flash — sometimes barely visible, sometimes long enough to notice — a page shows "No data available" right before the real data pops in and replaces it.
Why it breaks: A piece of data can be in one of at least three distinct states: still loading (we don't know yet), loaded and empty (we now know, and there's genuinely nothing), or loaded with content. The code was only checking two states — "do we have data?" and, if not, immediately assuming the answer was "confirmed empty" — without first checking whether the request was still in flight. It's the difference between a doctor saying "your results aren't back yet" and "the results say there's nothing there" — two completely different messages that look identical if you only check one box on the form.
The fix, in plain terms: Whenever a value can be absent because it hasn't arrived yet versus absent because there's truly nothing, keep those as separate, explicit states in your code rather than collapsing them into a single "is it missing?" check. Show a loading indicator for the first case and an empty-state message only for the second.
How to catch it early: Deliberately test on a slow or throttled network connection, not just a fast development machine. States that only exist for 100 milliseconds on a fast connection can last several full seconds on a slow one — plenty of time to notice they're wrong.
7. The silent typo with extra zeros
What it looks like: A success notification ("Saved!", "Submitted!") pops up — and then just never goes away. Minutes later, it's still sitting there.
Why it breaks: Somewhere in the code was a constant meant to represent "how long, in milliseconds, before this notification auto-dismisses." It was set to 1000000 — one million milliseconds, about sixteen and a half minutes — instead of 1000, one second. A single misplaced digit, in a raw number with no unit label attached, that nobody happened to glance at twice.
The fix, in plain terms: Raw numeric constants that represent a real-world duration, size, or quantity are one of the easiest places for a silent, undetectable-by-the-compiler typo to hide, because 1000 and 1000000 are both perfectly "valid" numbers as far as the code is concerned — nothing errors, nothing crashes, it just quietly does the wrong thing for a very long time. Where possible, write durations using explicit units your reader can sanity-check at a glance (a small helper like seconds(1) instead of a bare 1000), or at minimum, leave a comment stating the intended real-world value next to the raw number.
How to catch it early: For any timing-related constant, ask out loud: "does this number, read as a plain duration, sound right?" A million milliseconds is an unusual enough quantity that saying it out loud — "sixteen minutes" — would have caught it instantly; reading 1000000 silently on a screen does not carry the same signal.
8. Numbers that lie: parsing formatted text as data
What it looks like: A payment summary shows a wildly wrong total — not zero, not blank, just a number that's obviously too small compared to what it should be.
Why it breaks: The raw amount arrived from the server already formatted for human reading, as text — something like "12,450.00" — because it was meant for display elsewhere. The code then ran it through a standard "turn this text into a number" function. That function reads digits from left to right and simply stops the instant it hits a character it doesn't recognize as part of a number — and a comma is exactly that kind of character. So "12,450.00" didn't become 12,450. It became 12, because parsing stopped cold at the first comma.
The fix, in plain terms: Never assume a piece of text formatted for human display is safe to feed directly into something built for parsing raw data. Strip formatting characters (thousands separators, currency symbols, unit labels) explicitly before parsing, or better, get the raw unformatted number from the source in the first place and apply formatting only at the very last step, right before it's shown on screen.
How to catch it early: Test with a value large enough to actually contain a thousands separator. A two- or three-digit test amount will pass this exact bug every single time and tell you nothing is wrong.
9. A promise your API breaks
What it looks like: A "submit and automatically move to the next item" feature always falls back to sending the user back to a list screen instead — the auto-advance step silently never happens, on every single submission, without ever throwing a visible error.
Why it breaks: The frontend and backend had agreed, as part of their contract, that a successful submission response would include a field describing where to go next. The backend's implementation, however, only ever returned a bare success message with that field left empty — the feature to compute "what's next" had simply never been built on that side, even though the frontend had been written assuming it would always be there. Nothing crashes in a situation like this, because the frontend was written defensively enough to fall back gracefully — which is exactly why it can go unnoticed for a long time: the failure mode looks like a design choice ("oh, I guess it just goes back to the list") rather than an obvious error.
The fix, in plain terms: When two systems built by different people (or even the same person, weeks apart) communicate over an API, the shape of the data being exchanged is a contract, and contracts silently drift out of sync more often than either side expects. The fix isn't a code trick — it's process: whenever a frontend is built assuming a field will be populated, verify against the actual, current backend response, not against a spec document or an old memory of how it used to work.
How to catch it early: Log or inspect the raw response payload during integration testing, not just whether the request "succeeded." A response can report success and still be missing the specific piece of data your feature actually depends on.
10. Wiring bugs: code that's defined but never connected
What it looks like: A piece of logic exists in the codebase, looks entirely correct on its own, compiles without any errors or warnings — and yet, when you trace what actually happens while the app runs, it never executes at all.
Why it breaks: A setup function had been written to register a needed reference (in this case, wiring the app's central data store into a utility module so that the utility could react when a login session expired) — but the line of code that actually calls that setup function during app startup had never been added. Nothing about this raises an error: an unused-but-correctly-written function is not a syntax mistake, not a type mismatch, nothing a compiler or type-checker is designed to flag. It's a gap between "this code exists" and "this code runs," and that gap is invisible unless you specifically go looking for it.
The fix, in plain terms: Writing a function is not the same claim as connecting it. Whenever you add a piece of setup, initialization, or event-wiring logic, immediately trace backward: what calls this, and does that caller actually run during real app startup — not just in theory, but confirmed?
How to catch it early: Deliberately trigger the exact scenario the wiring was built for (in this case: let a session genuinely expire and watch what happens) rather than trusting that "the code is there" means "the code runs." A surprising number of dead-wiring bugs are caught simply by asking "when, exactly, does this function get called?" and being unable to answer.
11. Content that brings its own styling with it
What it looks like: Switch the app into dark mode, and most of the interface adapts correctly — except one specific block of text (in this case, mathematical formulas), which stays stubbornly dark-on-dark and becomes unreadable.
Why it breaks: Most of this app's text gets its color from the app's own centralized design system, which knows how to respond when dark mode is toggled. But this particular content wasn't written by the app at all — it was raw HTML, generated elsewhere (a formula-rendering tool) and inserted directly into the page as-is, carrying its own baked-in text color from whatever system produced it. That color has nothing to do with the app's theme, and no idea the app even has a dark mode — it's like ordering custom stationery pre-printed in black ink and then being surprised it doesn't switch to white ink when you dim the room.
The fix, in plain terms: Any time your app inserts content it didn't author itself — from a rich-text editor, a third-party widget, or a formula/markup renderer — assume that content carries its own opinions about color and formatting, and that your theme system has no authority over it by default. You have to explicitly and forcefully override it (the fix here targets every element inside that block and force-overrides its text color specifically under the dark theme) rather than expecting it to inherit your rules automatically.
How to catch it early: Whenever a feature renders content that came from outside your own component tree — pasted HTML, a third-party embed, server-rendered markup — check it specifically against every visual mode your app supports (dark mode, high contrast, print), not just the default one, since it's exactly the kind of content your normal styling review tends to skip past.
12. When "just reload the page" doesn't actually fix anything
What it looks like: The app hits an unexpected error and shows a generic crash screen — which is good — but the "Reload Page" button on that screen either does nothing useful, or the raw internal error message it displayed ("Cannot read properties of undefined (reading 'map')") is confusing and mildly alarming to an ordinary user.
Why it breaks: A crash-recovery screen (technically called an "error boundary" — a special component whose entire job is to catch a crash elsewhere in the app and show a fallback instead of a blank white screen) had two separate problems. First, it displayed the raw, technical error message straight from the code — meaningful to a developer, meaningless or even alarming to a regular user. Second, its recovery action was "reload the current page" — but if the crash was caused by something that persists across a reload, like a bad value stuck in the page's URL or in browser storage, reloading just re-triggers the same crash, with no way out except closing the tab entirely.
The fix, in plain terms: An error-recovery screen has two separate jobs, and it's easy to only do one of them: communicate clearly (a plain, non-technical, reassuring message — not the raw exception) and offer an escape that's actually guaranteed to work (send the user to a known-good starting point, like the home page, rather than assuming a reload of the broken page will magically resolve itself).
How to catch it early: When you build any error-recovery UI, ask "what if the thing that caused this crash is still true after the user clicks my recovery button?" If reloading wouldn't actually change the underlying condition, the recovery button needs to do something else — usually, navigate somewhere entirely different.
13. Fixing one platform's bug can silently break another's
What it looks like: A "download this file" button works fine in a normal desktop browser, works fine inside the app's iOS version, and then — after a fix specifically aimed at making it more reliable — quietly stops working inside the Android version of the same app, with no error message at all.
Why it breaks: Downloading a file sounds like one simple operation, but it behaves differently across environments in ways that aren't visible until you actually test each one: a regular browser handles it one way, an iOS in-app browser has no reliable way to do it at all (so it has to fall back to just opening the file instead of downloading it), and the Android in-app browser needs the file fetched into memory first and then handed off as a very specific technical object with its original file type intact. In this case, an earlier fix had deliberately relabeled that in-memory file as a generic "unknown file" type to solve a different problem — which, invisibly, broke the Android in-app browser's own downloader, because it refused to accept an in-memory file it couldn't recognize the type of. The fix for one platform became the bug on another.
The fix, in plain terms: When one code path has to serve multiple genuinely different environments (a browser, an iOS WebView, an Android WebView — each with its own quirks and limitations), treat each environment as needing its own explicitly tested path rather than one shared fix that "should" work everywhere. Layer in fallbacks in a defined order (try the ideal method, then a simpler one, then the most basic one that's guaranteed to at least do something) so that a failure in the best path degrades gracefully instead of failing silently.
How to catch it early: Any fix that touches shared, cross-platform code needs to be manually re-verified on every platform it's shared across, not just the one platform where the original bug was reported — the change that fixes environment A is exactly the kind of change that's most likely to have never been tested against environment B.
The takeaway
None of these bugs required advanced algorithms, obscure frameworks, or genius-level debugging. Every single one came from the same root shape: a piece of code trusted an assumption that was true at the moment it was written, and stopped being true later — a boundary that wasn't checked, a cache that wasn't invalidated, a format that wasn't normalized, a contract that quietly changed, a style rule that didn't apply where it was assumed to, a recovery path that assumed the problem would fix itself.
The individual fixes are specific to this project. The habit of asking "what assumption is this line quietly making, and what would make it false?" is not — it travels to literally any codebase, in any language, on any stack, for the rest of your career.