惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - 【当耐特】
Latest news
Latest news
IT之家
IT之家
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
L
LangChain Blog
腾讯CDC
J
Java Code Geeks
GbyAI
GbyAI
美团技术团队
V
Visual Studio Blog
Apple Machine Learning Research
Apple Machine Learning Research
Recorded Future
Recorded Future
U
Unit 42
Jina AI
Jina AI
月光博客
月光博客
罗磊的独立博客
I
InfoQ
有赞技术团队
有赞技术团队
B
Blog RSS Feed
The Register - Security
The Register - Security
WordPress大学
WordPress大学
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
MongoDB | Blog
MongoDB | Blog
NISL@THU
NISL@THU
S
Security Archives - TechRepublic
雷峰网
雷峰网
O
OpenAI News
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Y
Y Combinator Blog
G
GRAHAM CLULEY
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
L
LINUX DO - 热门话题
H
Help Net Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
Securelist
P
Proofpoint News Feed
C
Cybersecurity and Infrastructure Security Agency CISA
博客园 - 叶小钗
Security Latest
Security Latest
A
About on SuperTechFans
G
Google Developers Blog
T
Troy Hunt's Blog
小众软件
小众软件
H
Hacker News: Front Page
C
Cisco Blogs
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
L
LINUX DO - 最新话题
大猫的无限游戏
大猫的无限游戏
Webroot Blog
Webroot Blog

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
API Versioning Strategies: URL, Header, or Query - And How To Retire The Old One
The Practica · 2026-06-02 · via The Practical Developer

You rename a field from user_name to username because the rest of the codebase uses camelCase. The CI passes. The deploy goes green. Two hours later, support tickets start rolling in: the iOS app crashes on launch, the partner integration is returning nulls, and the billing pipeline just charged somebody twice because it parsed a renamed field as missing.

The three lines of rename cost more than the feature that made you rename it. And the root cause is not “we should have caught it in testing.” The root cause is that you changed a contract that other code depended on, and you gave those clients no way to upgrade at their own pace.

API versioning is the answer, but most teams pick a strategy by copying whatever the SaaS provider they admire uses. That is not a strategy. It is cargo-culting. URL versioning (/v1/users), header versioning (Accept: application/vnd.example.v2+json), and query-parameter versioning (/users?version=2) each impose different costs on your producers, your consumers, and your infrastructure. The right choice depends on who your clients are and how you deploy.

This post walks through each strategy with real trade-offs, gives you the backward-compatibility rules that prevent “versioning” from being a false promise, and ends with a deprecation playbook you can copy into your next project.

The minimum viable versioning decision

Before you pick a strategy, you need two things that have nothing to do with URLs or headers:

1. A written policy for what counts as a breaking change. Without this, versioning is theater. Your team will disagree on whether adding a field is breaking (it is not, for JSON APIs), whether reordering enum values is breaking (it is, for serialized code), and whether changing a 404 to a 403 is breaking (it is, for clients that parse status codes). Write it down. Enforce it in code review.

2. A way to run multiple versions simultaneously. You cannot version your API if your deployment model does not support running two versions of the same endpoint at the same time. That sounds obvious. It is also the thing teams discover six months in, when their monolith cannot serve /v1/users and /v2/users from the same process because the routing is hardcoded.

The three strategies

URL path versioning

GET /v1/users/42
GET /v2/users/42

This is the most common strategy, used by Stripe, Twilio, GitHub, and most public APIs. The version is part of the path. The router dispatches to different handler code based on that prefix.

What it costs you:

  • You now maintain N copies of every endpoint. If you have six versions live (unusual but possible with slow-migrating enterprise clients), you have six handlers, six sets of tests, six OpenAPI specs. Refactoring the internal logic means propagating changes across all of them or letting them diverge and hoping you remember which version has which behavior.
  • URL pollution. The path is the cleanest part of a RESTful design, and versioning it makes every route one level deeper.
  • Code organization can decay into copy-paste inheritance. Version 2 copies version 1’s handler and tweaks one field. Version 3 copies version 2 and tweaks another. Every version has the same bug, fixed in different ways.

What it buys you:

  • Maximum discoverability. Any client, any proxy, any curl command can see the version immediately. No special headers to set, no middleware to inspect.
  • Easy caching. A CDN caches /v1/users/42 and /v2/users/42 separately. The cache key is the URL.
  • Easy debugging. The URL tells you exactly which version ran. Logs, metrics, tracing all get the version for free.

When to use it: Public APIs with external clients that you cannot upgrade unilaterally, especially mobile apps whose users do not update immediately. The discoverability and caching wins matter more than the code duplication cost.

Accept: application/vnd.example.v2+json
# or
X-API-Version: 2

The version is negotiated through content-type headers or a custom header. The URL stays clean.

What it costs you:

  • Invisible to everyone. A developer hitting the API with curl gets v1 (the default) unless they know to set the header. Documentation becomes critical.
  • Poor caching. Most CDNs and reverse proxies do not inspect Accept headers by default. You need custom Vary headers, which fragment the cache or bypass it entirely.
  • Debugging friction. When a client reports a problem, the first question is “which version did you call?” and the answer is usually “I do not know, whatever the default is.” You have to dig through access logs or ask the client to send the header.
  • Testing complexity. Every integration test now needs to send a header instead of a URL. It is a small difference, but it compounds across hundreds of tests.

What it buys you:

  • Clean URLs. The resource path stays stable. If you believe RESTfully that /users/42 is a noun whose representation should be negotiated, this is the theoretically pure approach.
  • No path routing complexity. The same handler runs for all versions; it inspects the header and branches internally. This forces you toward shared internal code (a single handler with version-specific transforms) instead of copying handlers.
  • Backward-compatible defaults. New clients set the version header; old clients get the default. Nobody’s URL breaks.

When to use it: Internal APIs where you control both the client and the server, and where you care about URL aesthetics. Also useful for APIs where the client is a browser and you want to keep URLs bookmarkable across versions.

Do not use the custom header approach (X-API-Version) for public APIs. It does not follow HTTP semantics and it breaks in unexpected ways with proxies and gateways that strip unknown headers. Use Accept with a vendor media type if you go this route.

Query-parameter versioning

GET /users/42?version=2

The version is a query parameter. It is the easiest to implement (one middleware check) and the hardest to maintain.

What it costs you:

  • Cache poisoning. Query parameters are treated as part of the cache key, but clients that omit ?version=2 may get a cached response meant for v2, or vice versa, depending on your cache configuration.
  • Proxy confusion. Load balancers and API gateways often split traffic based on URL paths. Query parameters are invisible to most routing rules.
  • It encourages query-string pollution. Once “version” goes in the query string, every other configuration parameter follows, and your API becomes a grab bag of optional flags.
  • Poor documentation ergonomics. Developers expect versioning to be visible in the URL. Hiding it in a parameter makes the default version (no parameter) a hidden behavior.

What it buys you:

  • Dead simple to implement. One piece of middleware reads the query string and sets req.apiVersion. No routing changes, no header inspection.
  • Easy to default. No version = v1. Add ?version=2 to opt into the new behavior. Great for gradual rollouts of internal migration.
  • No URL pollution of the path. But at the cost of making the version invisible.

When to use it: Never for public APIs. Use it only for internal, short-lived version transitions where you are migrating one service at a time and plan to remove the parameter after 90 days. It is a migration tool, not a versioning strategy.

The backward-compatibility rules that actually hold

Regardless of which strategy you pick, your version promise is only as good as your backward-compatibility rules. Here are the rules that survive production.

Adding a field is not breaking. An extra key in a JSON response should be ignored by any well-written client. If it is not, that is a client bug, not an API break. Never bump a version for adding a field.

Changing a field’s type is breaking. String to number, number to string, null to array. Always a version bump.

Removing a field is breaking. Unless you have telemetry proving zero clients depend on it. You rarely have that telemetry. Assume every field has consumers. Bump the version.

Changing the order of enum values is breaking. Clients that use numeric index instead of the string value exist. You will never know about them until they break. Freeze enum order per version or version the enum.

Adding a new endpoint is not breaking. Existing clients do not call it. The API surface expands. No version bump.

Changing status codes is breaking. A client that parses 201 Created may not handle 200 OK. A client that expects 404 Not found for missing resources may not handle 403 Forbidden. Status codes are part of the contract.

Changing error shapes is breaking. If your v1 errors look like { "error": "message" } and your “backward-compatible” v2 errors look like { "code": "NOT_FOUND", "detail": "message" }, existing clients that parse error will silently swallow errors. Version bump.

Write these rules into your OpenAPI spec. Enforce them with a diff check in CI:

# Pseudo-code: diff the spec, fail on breaking changes
npx openapi-diff --old spec-v1.yaml --new spec-v2.yaml --fail-on-breaking

The OpenAPI Specification Diff tool or OasDiff can automate this. Run it in CI on every PR that touches the spec.

Internal branching: the pattern that saves your handlers

The biggest pain of versioning is maintaining parallel handlers. The fix is not to eliminate duplication entirely (some is inevitable) but to confine it to a thin transform layer.

request
  |
  v
router ──→ version resolver ──→ business logic (shared)
                                     |
                                     v
                               response formatter (version-specific)

The business logic runs once. The version-specific part is only the response shape and any input parsing differences. If you find yourself copying an entire handler to change one field, you have not versioned your API; you have forked your codebase.

// Bad: forked handlers
async function getUserV1(id: string) {
  const user = await db.findUser(id);
  return { user_name: user.name, email_addr: user.email };
}

async function getUserV2(id: string) {
  const user = await db.findUser(id);
  return { username: user.name, email: user.email, role: user.role };
}

// Better: shared logic, versioned transform
async function getUser(id: string, version: 1 | 2) {
  const user = await db.findUser(id);
  const response = { name: user.name, email: user.email, role: user.role };

  if (version === 1) {
    return {
      user_name: response.name,
      email_addr: response.email,
    };
  }

  return {
    username: response.name,
    email: response.email,
    role: response.role,
  };
}

This pattern keeps the database query and business rules in one place. The version switch is a translation layer at the boundary. When you eventually drop v1, you delete the if (version === 1) branch and rename the response keys. One file changes, not a whole handler.

The sunset playbook

Versioning without sunsetting is just accumulating cruft. Every major API I have worked on that is older than five years has at least three versions live, nobody knows which clients use the oldest one, and the team is afraid to remove it.

Follow this playbook instead.

Step 1: Track usage. Every response includes a version identifier. Log it. Aggregate by client ID. Know which clients call which version, and when they last called it.

// Response header
api-supported-versions: 1, 2, 3
api-deprecated-versions: 1
// Middleware to log usage
app.use((req, res, next) => {
  res.on('finish', () => {
    logger.info({
      path: req.path,
      version: req.apiVersion,
      clientId: req.headers['x-client-id'] ?? 'unknown',
      status: res.statusCode,
    });
  });
  next();
});

Step 2: Announce deprecation. Set a sunset date at least six months out. Add a Sunset header to responses for the deprecated version. Add a Warning header with a descriptive message.

Sunset: Sat, 01 Dec 2026 23:59:59 GMT
Warning: 299 - "v1 is deprecated. Migrate to v2. See https://docs.example.com/migration-guide"

Step 3: Surface migration guides. For every breaking change between v1 and v2, write exactly one migration step. “Rename user_name to username” is one step. “Restructure your entire request flow” is multiple steps, which means you are doing too much in one version bump. Break it into v2 and v3.

Step 4: Freeze the deprecated version. No new features. Security patches only. Document that the version is in maintenance mode so teams do not accidentally build on top of it.

Step 5: Drop it when usage hits zero. Not when the sunset date passes. When usage hits zero. If you set a date and usage is not zero, you either extend the date or you are breaking clients that chose not to migrate. The sunset date is for you to plan the work, not for the clients to migrate by. They will migrate when they are ready.

I have never seen an API version get to zero usage on the scheduled date. Plan for a 12-18 month overlap window between releasing vN+1 and fully decommissioning vN.

What this looks like in practice

Here is the full wiring for URL-based versioning in a Fastify app:

import Fastify from 'fastify';

const app = Fastify();

// Shared business logic
async function getUserData(id: string) {
  return db.findUser(id);
}

// v1 handler
app.get('/v1/users/:id', async (req, reply) => {
  const user = await getUserData(req.params.id);
  return { user_name: user.name, email_addr: user.email };
});

// v2 handler
app.get('/v2/users/:id', async (req, reply) => {
  const user = await getUserData(req.params.id);
  return { username: user.name, email: user.email, role: user.role };
});

// Deprecation middleware
app.addHook('onSend', (req, reply, payload, done) => {
  if (req.url.startsWith('/v1/')) {
    reply.header('Sunset', 'Sat, 01 Dec 2026 23:59:59 GMT');
    reply.header('Warning', '299 - "v1 is deprecated. Migrate to v2."');
    reply.header('api-supported-versions', '1, 2');
    reply.header('api-deprecated-versions', '1');
  }
  done();
});

For header-based versioning in Express:

import express from 'express';

const app = express();

app.get('/users/:id', (req, res) => {
  const version = parseVersion(req.headers['accept']);

  const user = getUserData(req.params.id); // shared

  if (version === 1) {
    return res.json({ user_name: user.name, email_addr: user.email });
  }
  if (version === 2) {
    return res.json({ username: user.name, email: user.email, role: user.role });
  }
});

function parseVersion(accept?: string): number {
  if (!accept) return 1;
  const match = accept.match(/application\/vnd\.example\.v(\d+)\+json/);
  return match ? parseInt(match[1], 10) : 1;
}

The takeaway

Pick URL versioning for public APIs and header versioning for internal ones. Never use query-parameter versioning for anything that lives longer than one migration cycle. Write down what counts as a breaking change and enforce it with a spec diff in CI. Share business logic across versions, translate at the boundary. Track usage, set a sunset date six months out, freeze the deprecated version, and drop it when usage hits zero, not when the calendar says so.

The API you ship today will be maintained by somebody who has never met you. Give them a versioning story that does not require reading your mind.


A note from Yojji

Building APIs that serve multiple client versions simultaneously without accruing technical debt is the kind of architectural maturity that separates a platform from a prototype. Yojji’s teams design versioning strategies from day one, bake deprecation tracking into the response layer, and keep shared business logic from duplicating across versions so that when the old one finally sunsets, the cleanup is measured in hours, not months.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product engineering from discovery through DevOps.