The Simple Bug That Started a Bigger Conversation: PATCH, Null Handling, and Engineering Judgment

Published
•12 min read•View as Markdown
The Simple Bug That Started a Bigger Conversation: PATCH, Null Handling, and Engineering Judgment
J

I write technical articles that analyze real world engineering problems through the lens of production systems.

My work focuses on architectural tradeoffs, UI layer system design, performance optimization, and reliability patterns derived from shipping and maintaining scalable applications.

Sometimes discovering what you don’t need to build is more valuable than what you do.

This week, while testing a small form update before pushing to production, I discovered something that would have silently corrupted user data. A user clears their email field, hits save, refreshes the page, and the old email is still there. No error. No warning. Just wrong.

This article isn’t about that bug specifically. It’s about what happened next: a technical discussion that almost derailed a simple fix, and what it revealed about how we think about API design, best practices, and engineering tradeoffs.

The Bug: Silent Field Persistence

The issue was straightforward:

Expected behavior:

User clears any optional field (email, phone, address, or any nullable attribute across the system) → field removed from database

Actual behavior:

User clears field → old value persists silently

From the browser network tab:

PATCH /api/candidates/123
{
  "email": null,
  "contactNumber": null
}

Response: 200 OK
Database: Values unchanged

This is the dangerous kind of bug, no stack trace, no error boundary, just incorrect state. Users think they’ve removed their information, but it’s still there.

The Root Cause: A Common Pattern

The backend update handler looked like this:

function updateCandidate(id, payload) {
  const updates = {};

  for (const [key, value] of Object.entries(payload)) {
    if (value) {  // ← The issue
      updates[key] = value;
    }
  }

  return db.update('candidates', id, updates);
}

The if (value) check seems reasonable, it's defensive programming, preventing accidental overwrites with empty values.

But in JavaScript, null is falsy. So if (null) evaluates to false, the field never enters the updates object, and the database never sees the change.

This pattern appears everywhere. It reads cleanly, feels safe, and breaks silently. This bug turned out to be one instance of a broader class of failures: implementation patterns that feel defensive but subtly violate protocol semantics.

The Discussion That Followed

When I raised this issue, the conversation naturally expanded beyond the immediate fix:

  • “Should we implement dirty field tracking?”

  • “We need to send only changed fields.”

  • “Doesn’t this violate PATCH best practices?”

  • “What about using a sentinel value like CLEAR?”

These were valid suggestions, each addressing real concerns about API design, performance, and maintainability. Within minutes, we’d gone from a clear bug to a broader discussion about state management, API contracts, and optimization strategies.

The suggestions had technical merit. But as the conversation unfolded, I realized we were solving a different problem than the one we had.

The Question That Mattered

What should have been asked immediately:

“What is actually broken, and what’s the smallest fix?”

Let me be precise about what our production problems were NOT:

  • Payload size (requests ~2KB, traffic ~2,000/day)

  • Bandwidth costs (would save ~$4/year optimizing)

  • Network latency

  • Race conditions

  • Unclear user intent

What our actual problem WAS:

  • Backend ignores null in PATCH payloads

  • Users cannot clear optional fields

  • Silent data integrity issue

That’s it. Everything else was theoretical optimization.

PATCH Semantics: What the Specs Say

RFC 7396 (JSON Merge Patch) is explicit:

{
  "email": null
}

This is valid. The server MUST interpret it as “set this field to null”, not “ignore this field.”

The RFC defines three cases:

  • Field present with value → Update to that value

  • Field present with null → Clear the field

  • Field absent → Leave unchanged

Any implementation that doesn’t handle all three has a semantic bug.

Here’s what the RFC does NOT say:

  • “Clients must compute diffs”

  • “Sending unchanged fields is invalid”

  • “Dirty tracking is required”

  • “Only send modified fields”

Sending the full object in a PATCH is less optimal but not incorrect. The protocol supports it. Many production systems rely on it.

Real-World Examples: How Production APIs Actually Work

GitHub API

GitHub’s PATCH endpoints accept full payloads and their documentation shows examples sending complete objects including unchanged fields like name, description, homepage, private, has_issues, has_projects, and has_wiki together in single requests. Many official SDKs and community clients send complete objects even when only one field changes. GitHub’s backend processes whatever fields are present.

Stripe API

Stripe’s update customer endpoint states: “Any parameters not provided will be left unchanged,” which means you can send partial or complete objects. Their webhook update endpoints explicitly support clearing metadata. Individual keys can be unset by posting an empty value to them, and all keys can be unset by posting an empty value to metadata. This uses null or empty values as clear signals without requiring clients to compute diffs.

Salesforce API

Salesforce’s REST API uses PATCH for updates: “Provide the updated record information in your request data and use the PATCH method of the resource with a specific record ID”. Their implementation doesn’t mandate minimal payloads. It processes the fields you send.

These are high-stakes, production systems handling millions of requests daily. They all:

  • Accept PATCH with full or partial payloads

  • Process fields based on presence, not minimization

  • Handle null correctly as a clear instruction

  • Don’t require client-side dirty tracking

The common pattern: server-side logic determines semantics, client-side optimization is optional.

Why Dirty Tracking Doesn’t Fix This

Dirty field tracking is useful for:

UX improvements:

  • Disable “Save” when nothing changed

  • Show “unsaved changes” warnings

  • Highlight modified fields

Performance optimizations:

  • Reduce large payload sizes

  • Skip expensive validations

  • Minimize database writes

Correctness in specific scenarios:

  • Concurrent editing conflict detection

  • Explicit intent signaling

These are valuable. But notice what’s missing:

“Making null handling work”

Dirty tracking changes what you send. It doesn’t change how the backend processes what it receives. You can implement perfect dirty tracking and still have this exact bug if the backend ignores null.

The Premature Optimization Trap

Implementing dirty tracking would require:

Frontend:

  • Snapshot original state on load

  • Deep equality comparison on change

  • Handle reset/revert flows

  • Manage nested object diffing

  • Update TypeScript types

Testing:

  • Unit tests for diff logic

  • Integration tests for edge cases

  • Regression tests for resets

  • Performance tests for large forms

Documentation:

  • Why it exists

  • How to use correctly

  • Edge cases and limitations

  • Migration guide

Estimated timeline: 2–3 weeks
Estimated benefit at our scale: ~300MB bandwidth/year ($0.30), slightly better UX

Meanwhile, users still can’t clear their email.

This is how technical debt gets created in the name of “doing it right.” Every abstraction has a carrying cost. Every optimization has an opportunity cost.

The Idempotency Insight

Here’s something that emerged from thinking about the bug more deeply:

With buggy null handling, our endpoint wasn’t truly idempotent. Send this once:

PATCH /api/candidates/123
{ "email": null }

First call: Email unchanged (bug ignores null)
Second call: Email unchanged (still ignored)

Appears idempotent, but for the wrong reason: the implementation masked the violation.

After fixing:

First call: Email cleared
Second call: Email still null (correctly idempotent)

If you have retry logic, circuit breakers, or message queues that replay requests, the buggy and fixed versions have different semantics. The bug was hiding a deeper architectural property.

This is why correctness precedes optimization. Optimizations are incremental improvements. Correctness violations compound through the system.

The Sentinel Value Anti-Pattern

One suggestion was using magic strings:

{
  "email": "__CLEAR__"
}

This seems pragmatic. It avoids null handling, makes intent explicit, and works immediately.

It’s also wrong:

Type system violation:

interface Candidate {
  email: string | null;  // Type says: string or null
}
// But now this is valid?
const payload = { email: "__CLEAR__" };  // Actually a command

Our types lie. Every consumer needs special knowledge.

Leaky abstraction:

The backend’s limitation now leaks into frontend code, API docs, mobile apps, third-party integrations. Everyone must know: “Don’t send null. Send this magic string.”

Unclear semantics:

{
  "email": "__CLEAR__",
  "backup_email": ""
}

Maintenance burden:

Now you need constants, validation, documentation, migration paths, and special handling in logs and analytics, all to avoid writing:

if (payload.hasOwnProperty(key)) {
  updates[key] = payload[key];
}

If the protocol has a solution, use it. Inventing alternatives creates debt disguised as pragmatism.

The Actual Fix (What We’re Doing)

Backend (5 minutes):

function updateCandidate(id, payload) {
  const updates = {};

  for (const [key, value] of Object.entries(payload)) {
    if (payload.hasOwnProperty(key)) {  // Check presence
      updates[key] = value;  // Includes null
    }
  }

  return db.update('candidates', id, updates);
}

Frontend (2 minutes):

const payload = {
  email: form.email?.trim() || null,
  contactNumber: form.contactNumber?.trim() || null,
  address: form.address?.trim() || null
};
await api.patch(`/candidates/${id}`, payload);

Documentation (ongoing):

  • Update API docs to clarify null handling

  • Add examples showing field clearing

  • Document the contract explicitly

Total time: Half a day
Result: Bug fixed, contract clarified, ready to ship

We’re not implementing dirty tracking. We’re not minimizing payloads. We’re fixing what’s broken.

When Optimization Actually Matters

I’m not against dirty tracking or payload optimization. I’m against doing them now, for this problem, at our scale.

Dirty tracking makes sense when:

  • Forms have 50+ fields (ours: 12)

  • Validations are expensive (ours: <10ms)

  • Mobile data usage matters (we’re desktop-first)

  • You have data showing users abandon changes frequently

Payload optimization makes sense when:

  • Requests regularly exceed 100KB (ours: ~2KB)

  • Traffic exceeds 100K requests/day (ours: approx. 2–5K/day)

  • Bandwidth costs are measurable (we’d save $4/year)

  • Network latency is high

None of these are true for us. Building for them anyway is speculative engineering, solving hypothetical problems based on traffic we don’t have and costs we don’t pay.

This feels responsible, but it’s actually premature optimization dressed up as best practices. If those conditions change, such as higher traffic, mobile-first usage, or measurable performance degradation, optimization becomes the right move. The difference is sequencing: we’ll respond to evidence, not anticipation, with the confidence that we didn’t block delivery while waiting for problems that hadn’t materialized.

When We’ll Know We Chose Wrong

I’m watching for specific signals that would indicate the simple fix is no longer scaling:

  • p95 latency climbing above 200ms at 50K+ requests per day

  • User analytics showing more than 40% abandonment on form changes

  • The introduction of real-time collaboration exposing race conditions

  • The same category of contract violations (truthy checks, sentinel values, etc.) appearing across multiple endpoints despite documentation

These would be measured problems backed by data, not speculation. When we see them, we’ll have clear evidence that justifies additional complexity. Right now, we don’t.

A Framework for Evaluating “Best Practices”

After this experience, I now ask:

  1. What is the actual problem?
    - Not the category, the specific, measurable issue
    - Current evidence, not hypothetical future

  2. What’s the simplest fix?
    - Minimum code change
    - Minimum new abstractions
    - Minimum test surface

  3. What’s the cost of being wrong?
    - Data corruption > Performance
    - User-facing bugs > Code elegance
    - Correctness > Optimization

  4. Is this reversible?
    - Can we add optimization later? (Usually yes)
    - Can we fix broken contracts later? (Usually painful)
    - Which direction has lower switching costs?

  5. Do we have data?
    - Actual metrics, not estimates
    - Real costs, not projected
    - Measured problems, not theoretical

The Deeper Lesson

The real insight wasn’t about PATCH or null handling. It was about how engineers approach problems.

When you see a bug, there are three questions:

  1. What’s broken? (Diagnosis)

  2. Why is it broken? (Root cause)

  3. What systemic issues enabled this? (Prevention)

For us:

  • What’s broken? Null values ignored in PATCH

  • Why? Truthy check instead of presence check

  • What’s systemic? No contract tests, unclear PATCH semantics across codebase, no shared API guidelines

The last question matters most for preventing recurrence.

These changes cost a few hours, but their impact compounds. Over the next year, the checklist alone will likely prevent eight to ten similar bugs based on our current velocity. API design discussions that once took thirty to sixty minutes now take five to ten because we have shared guidelines. New engineers can reference documentation instead of asking, “How do we usually handle this?” That’s organizational leverage. It makes the next ten decisions easier, not just one problem solvable.

Treating this as a teaching moment was intentional. Rather than fixing the bug quietly, we used it to align on how we reason about API contracts, correctness, and optimization. The goal wasn’t to prevent this one failure. It was to reduce the likelihood of entire categories of similar failures appearing elsewhere.

We’re making systemic improvements:

1. Pre-PR checklist for PATCH endpoints:

  • □ Handles null explicitly

  • □ Handles absent fields correctly

  • □ Contract tests include null cases

  • □ Idempotency verified

2. Shared API guidelines:

  • When to use PATCH vs PUT

  • How to handle null vs undefined vs omitted

  • Required test coverage

  • Reference implementations

3. Documentation updates:

  • Clarify null semantics in API docs

  • Add examples of field clearing

  • Document the explicit contract

These changes cost a few hours but prevent entire categories of bugs. That’s the real value, not optimizing hypothetical performance but hardening actual weak points.

Final Thought: The Discipline of Simplicity

The hardest part of engineering isn’t building complex systems. It’s having the discipline to keep them simple.

Every engineer has an instinct to solve the “real” problem, the elegant, general, future-proof solution. We’re trained for edge cases, scale, and architecture.

That instinct is valuable. It’s also dangerous.

Because sometimes the real problem is simple. And solving simple problems simply requires resisting the urge to be clever.

Adding complexity is easy. Maintaining simplicity is hard.

The best engineers don’t rely on instant pattern recognition alone. They build frameworks, write things down, and think through tradeoffs explicitly. Experience speeds up the process, but the process itself is what matters.

Anyone can introduce dirty tracking, sentinel values, sophisticated state management. It takes more discipline to write:

if (payload.hasOwnProperty(key)) {
  updates[key] = payload[key];
}

…and ship it.

What This Means Practically

If you’re in a similar situation, a “simple” bug becoming an architecture debate, ask yourself:

Am I solving the problem I have, or the problem I think I should have?

The answer often determines whether you spend weeks building infrastructure or ship a fix the same day, and whether your team builds shared judgment or accumulates silent complexity.

Key Takeaways

  • Correctness before optimization, always

  • Best practices are context-dependent, not universal laws

  • The smallest working fix is often the right fix

  • Complexity has a carrying cost. Pay it deliberately

  • Test the contract, not just the implementation

  • Fix the system that created the bug, not just the bug itself

  • Real-world APIs (GitHub, Stripe, Salesforce) accept full PATCH payloads

  • Engineering judgment is knowing what not to build

This was discovered during routine testing, before production deployment. The fix is being implemented as part of normal development workflow. What matters isn’t that we found the perfect solution. It’s that we built the system to recognize when simple is sufficient and when complexity is justified. That distinction, repeated across hundreds of decisions, is what separates high-velocity teams from those drowning in their own sophistication.

Note: This article was originally published on Medium and is republished here for wider access and long-term reference.