What cross-cutting concerns are and how they sort into categories
A cross-cutting concern is behavior that every service needs but that isn't part of any one service's business logic. Examples include encrypting traffic, retrying a failed call, and emitting a trace span. Because these concerns repeat across services, teams want to implement them once. The real question is where that one implementation lives: in a process beside the application (a sidecar) or inside it (a library).
This article's thesis is that sidecar vs library is a per-concern decision, not a platform-wide one. Most production failures come from putting a specific concern on the wrong side rather than from choosing the wrong overall architecture. One example is retries configured in a proxy that can't tell whether a request is safe to repeat. Another is mTLS reimplemented in five language runtimes with five different rotation bugs.
A useful test is whether the concern needs to understand the request's meaning or only its bytes and headers.
| Category | Examples | Leans toward | Why |
|---|---|---|---|
| Transport security | mTLS, certificate rotation | Sidecar | Needs no request semantics; uniform enforcement matters more than flexibility |
| Traffic management | Load balancing, retries, timeouts, circuit breaking | Both | Connection-level work fits a proxy; retry safety depends on the operation |
| Observability | Metrics, traces, logs | Both | A proxy sees network timing; only the app sees internal spans and business context |
| Policy | Authorization, rate limiting | Both | Coarse rules fit a proxy; per-tenant or per-resource rules need app data |
| App-aware behavior | Idempotency, fallbacks, feature flags | Library | Requires domain knowledge a proxy cannot see |
How a sidecar and a library work mechanically
A library is code linked into your service's process. When the HTTP client retries a call, it runs on the same thread or event loop as your handler. It can read the caller's deadline, the tenant ID, whether the operation is idempotent, and the exception type your code will see. A library operates on in-memory objects before serialization, so it has the full request context.
A sidecar is a separate proxy process running in the same pod and sharing its network namespace. Traffic reaches it in one of two ways. With transparent interception, iptables rules redirect the app's outbound and inbound connections to the proxy's listener, and the app doesn't know the proxy exists. With explicit routing, the app is configured to call a localhost port. In both cases the proxy sees only what is on the wire: headers, method, path, status codes, timing, and TLS metadata. It cannot know that a POST is safe to retry unless something in the request tells it.
A single call now takes this path:
- Caller app → local proxy (loopback hop)
- Local proxy → network → remote proxy
- Remote proxy → remote app (loopback hop)
That is two extra hops per request, and each one parses, buffers, and applies policy. When a call fails during debugging, the error could come from either proxy or from the remote app. A 503 might mean the upstream app returned it, the local proxy found no healthy endpoints, or the remote proxy rejected the connection. To tell these apart you need to read proxy access logs and response flags alongside the application logs.
LIBRARY: policy runs in-process, before serialization
+---------- Pod A -----------+ +---------- Pod B -----------+
| +------------------------+ | | +--------+ |
| | handler -> client lib |----------------------------->| Remote | |
| | sees deadline, tenant, | | | | app | |
| | idempotency, errors | | | +--------+ |
| +------------------------+ | | |
+----------------------------+ +----------------------------+
SIDECAR: proxy in same pod, sees only what is on the wire
+---------- Pod A -----------+ +---------- Pod B -----------+
| +--------+ +---------+ | | +---------+ +--------+ |
| | Caller |-(1)>| Local |-----(2)----->| Remote |-(3)>| Remote | |
| | app | lo | proxy | | | | proxy | lo | app | |
| +--------+ +---------+ | | +---------+ +--------+ |
| iptables / localhost | | iptables redirect |
+----------------------------+ +----------------------------+
^ ^ ^
[F1] [F2] [F3]
(1),(3) loopback hops (2) network hop: two extra proxy hops per call
A 503 can originate at [F1] local proxy (no healthy endpoints),
[F2] remote proxy (rejected connection), or [F3] the remote app.
A library applies policy inside the app process with full request context, while a sidecar adds two proxy hops that see only wire data and creates three places (local proxy, remote proxy, remote app) where a failure such as a 503 can originate.
Why teams use a service mesh sidecar, and where the semantic gap bites
The strongest argument for a sidecar is that it is one implementation. A Go service, a Python worker and a JVM monolith all get the same mTLS handshake, retry logic and metrics, and the platform team can upgrade the proxy fleet without asking application teams to rebuild or redeploy. The proxy can also enforce workload identity, mTLS and coarse traffic policy even when application code is buggy or compromised. That guarantee holds only if traffic interception cannot be bypassed and the mesh configuration is correct.
Libraries drift. Each language needs its own implementation, and each one behaves slightly differently. A security fix means coordinated version bumps across dozens of repositories, and services nobody actively maintains keep running old versions for years. Enforcement depends on every team adopting the library and configuring it correctly.
The semantic gap
A proxy sees bytes, headers and status codes. It does not see intent. It cannot know whether a particular POST is idempotent and safe to retry, or whether a retry will charge a card twice. It cannot tell which of three outbound calls belong to the same checkout operation, so it cannot apply a shared deadline to them. It also cannot pick a sensible fallback, such as serving cached prices or hiding a recommendations panel.
Tracing shows the gap clearly. The sidecar can generate spans for each hop, but it cannot link an inbound request to the outbound calls the application makes while handling it. The application must copy trace context headers, such as W3C traceparent, from the inbound request onto every outbound call. Without that propagation you get disconnected fragments rather than traces, so zero-code tracing from a mesh is not realistic.
# Minimal Flask handler showing what the app must do so the mesh can stitch a trace.
# The sidecar creates spans per hop, but only the app knows which outbound calls
# belong to which inbound request, so the app must forward the context headers.
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
# W3C Trace Context headers. Add your mesh's own headers (for example B3 or
# x-request-id) if its tracer expects them.
TRACE_HEADERS = ("traceparent", "tracestate")
def propagated_headers():
# Copy trace context from the inbound request onto the outbound call.
return {h: request.headers[h] for h in TRACE_HEADERS if h in request.headers}
@app.post("/checkout")
def checkout():
ctx = propagated_headers()
# Both calls belong to the same checkout operation. Only the app knows that.
cart = requests.get("", headers=ctx, timeout=2)
cart.raise_for_status()
# Only the app knows this POST is unsafe to blindly retry. It passes an
# idempotency key the payments service can use to detect duplicates.
pay = requests.post(
"",
json={"cart_id": 42, "amount_cents": cart.json()["total_cents"]},
headers={**ctx, "Idempotency-Key": "order-42-attempt-1"},
timeout=5,
)
pay.raise_for_status()
return jsonify(status="ok")
# Drop propagated_headers() and the sidecars still emit spans, but the calls to
# cart and payments start new traces instead of joining the checkout trace.
A Python handler that copies W3C trace context from the inbound request to each outbound call and attaches an idempotency key, two pieces of request intent that a sidecar cannot infer by itself.
How to implement retries and timeouts without amplification
Retries compound multiplicatively. If a library retries a call and the sidecar beneath it also retries, each library attempt becomes several sidecar attempts. Repeat that across every hop and a single user request can turn into a storm aimed at the deepest, often already-struggling, service.
Illustrative estimate (assumed numbers, not a benchmark). Assume 3 attempts in the library, 3 in the sidecar, and a chain of 3 hops (A → B → C → D). The formula is worst-case attempts at the last hop = (library attempts × sidecar attempts) ^ hops = (3 × 3)^3 = 729. Substitute your own values. Even 2 × 2 across 4 hops gives 256.
Give retries one owner
- Library: use when retry decisions depend on idempotency. The code knows whether a
POSTcarries an idempotency key. The proxy does not. - Sidecar: restrict retries to safe cases, such as connection failures before any bytes were sent or explicitly idempotent methods. Set the library to zero retries.
Cap total retries with a retry budget. For example, allow retries only while they stay under a fixed fraction of recent requests (say 10%), and otherwise fail fast.
Propagate deadlines
Compute an absolute deadline at the edge and pass it downstream in a header. Each hop derives its timeout as remaining = deadline − now − local_margin. If remaining ≤ 0, it rejects the call instead of starting work no caller will wait for. This keeps inner timeouts shorter than outer ones.
Count once
When both layers emit request metrics, retries appear twice and error rates look inflated. Choose one layer as the source for request and error metrics, and tag retry attempts separately.
import random, time
# Generic sketch. send(), RetryableError and metrics are placeholders for your stack.
DEADLINE_HEADER = "x-deadline-ms" # absolute epoch ms, set once at the edge
LOCAL_MARGIN_S = 0.050 # time reserved to handle the response locally
MAX_ATTEMPTS = 3 # the library owns retries; sidecar retries are off
class RetryBudget:
"""Allow retries only while they stay under `ratio` of recent requests."""
def __init__(self, ratio=0.10, min_retries=5):
self.ratio, self.min_retries = ratio, min_retries
self.requests = self.retries = 0 # reset on a fixed window, e.g. every 10 s
def record_request(self):
self.requests += 1
def try_spend(self):
if self.retries < max(self.min_retries, self.ratio * self.requests):
self.retries += 1
return True
return False # budget exhausted: fail fast instead of piling on
budget = RetryBudget()
def call_downstream(send, request, incoming_headers, idempotent):
# Reuse the caller's absolute deadline. Never extend it at an inner hop.
deadline = float(incoming_headers[DEADLINE_HEADER]) / 1000
budget.record_request()
for attempt in range(MAX_ATTEMPTS):
remaining = deadline - time.time() - LOCAL_MARGIN_S
if remaining <= 0:
raise TimeoutError("deadline passed; not starting work")
headers = {DEADLINE_HEADER: incoming_headers[DEADLINE_HEADER]}
try:
return send(request, headers=headers, timeout=remaining) # inner < outer
except RetryableError:
# Retry only idempotent calls (or calls with an idempotency key), within budget.
if attempt == MAX_ATTEMPTS - 1 or not idempotent or not budget.try_spend():
raise
metrics.increment("retry_attempts") # tagged separately; one layer owns request/error counts
time.sleep(min(random.uniform(0, 0.1 * 2 ** attempt), max(remaining, 0)))
Library-owned retries forward an absolute deadline to the next hop, derive each attempt's timeout from the time remaining, retry only idempotent calls within a ratio-based retry budget, and count retry attempts as their own metric.
Envoy sidecar overhead and impact on cost and engineering time
Every call in a sidecar mesh passes through two extra proxies: the caller's outbound sidecar and the callee's inbound sidecar. Each hop adds parsing, filter-chain work and a loopback copy. A request that fans out across five services pays this cost ten times. Published latency figures vary widely with configuration, payload size and enabled features such as mTLS, access logging and tracing, so benchmark your own traffic at realistic percentiles.
Resource cost scales with pod count, not with traffic. Each sidecar carries its own CPU and memory requests whether or not the pod is busy.
Illustrative estimate (assumed numbers, not a benchmark). Assume 400 pods, with each sidecar requesting 100 MiB of memory and 0.1 vCPU.
- Reserved memory = pods × memory per sidecar = 400 × 100 MiB ≈ 39 GiB
- Reserved CPU = pods × CPU per sidecar = 400 × 0.1 = 40 vCPU
Engineering cost moves between teams rather than disappearing.
| Cost dimension | Sidecar (mesh) | Library |
|---|---|---|
| Runtime resources | Per-pod proxy reservation | Shared with app process |
| Latency | Two extra hops per call | In-process |
| Upgrades | Platform team rolls proxies | Every service redeploys, per language |
| mTLS and policy rollout | Central, one change | Coordinated across repos |
| Debugging | Extra layer to inspect | Single process and stack trace |
| Lifecycle toil | Injection, startup ordering, drain | None beyond the app |
The mesh saves time when many languages need consistent security and policy. Libraries save time when the service fleet uses few languages and the teams value having fewer moving parts.
When to avoid sidecars: operational failure modes and blast radius
Sidecars add failure modes that a library never has, because the proxy is a separate process with its own lifecycle.
- Startup ordering. If the app container starts first and makes outbound calls before the proxy is listening, those calls fail. These failures happen only at boot, so they are easy to miss in testing.
- Shutdown ordering. If the proxy receives SIGTERM and exits while the app is still draining, in-flight requests are cut off mid-response.
- Jobs that never finish. A batch container exits, but the proxy keeps running, so the pod never reaches a completed state.
- Shared blast radius. A bad proxy config or a faulty control-plane push reaches every meshed service at roughly the same time. A library bug is limited to the services that upgraded to the affected version, and each of those rolls out on its own schedule.
Orchestrators handle these lifecycle problems differently. Some offer native sidecar ordering and some do not, so test the behavior on your own platform. Don't assume it works.
The pod spec example shows the relevant settings:
- a
readinessProbeso traffic arrives only after the app is ready - a
preStophook that delays shutdown long enough for endpoints to deregister - a
terminationGracePeriodSecondsvalue longer than your longest request
A mesh usually isn't worth it for a handful of services written in one language, where a shared library delivers the same behavior without an extra hop. Library-only starts to break down as the number of languages and teams grows. Each language needs its own port of the library, and keeping those ports in sync on retry semantics, mTLS, and telemetry becomes ongoing work for several teams.
apiVersion: v1
kind: Pod
metadata:
name: orders-api
spec:
# Must exceed the preStop delay plus your longest in-flight request.
terminationGracePeriodSeconds: 45
containers:
- name: app
image: registry.example.com/orders-api:1.4.2
ports:
- containerPort: 8080
# Endpoints receive traffic only after this probe passes.
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
periodSeconds: 5
failureThreshold: 3
lifecycle:
preStop:
# Gives endpoint removal time to propagate before SIGTERM reaches the app.
# Requires a sleep binary in the image.
exec:
command: ["sleep", "10"]
- name: proxy
image: registry.example.com/mesh-proxy:placeholder
ports:
- containerPort: 15001
readinessProbe:
tcpSocket:
port: 15001
periodSeconds: 5
lifecycle:
preStop:
# Keeps the proxy up longer than the app so draining requests can finish.
# This is a convention, not a guarantee. Verify ordering on your platform.
exec:
command: ["sleep", "30"]
A conceptual pod spec that gates traffic with readiness probes, delays shutdown with preStop hooks, keeps the proxy running longer than the app during termination, and sets a grace period that covers both delays.
Alternatives and a per-concern decision checklist
work. Three other design points sit between the two extremes.
Alternatives as design points
- Proxyless mesh clients. gRPC clients can take routing, load-balancing, and security config straight from the mesh control plane. You remove the extra hop and keep request semantics in-process. The cost is that enforcement depends on the app linking a current client, and coverage stops at languages with a mature gRPC implementation.
- Node-level or shared proxies. One proxy per node or per pool replaces one per pod. Memory and sidecar injection drop out of the pod lifecycle, but tenants now share a failure domain. A noisy or misconfigured workload can degrade its neighbours.
- Kernel-level networking. L3/L4 policy, encryption, and flow metrics can be handled in the kernel, which is cheap and hard for the app to bypass. The kernel does not see HTTP routes or gRPC methods, so L7 decisions still need a proxy or a library.
Per-concern checklist
- Does it need request or business semantics?
- Must it hold if the app is buggy or compromised?
- How many languages would need a port?
- Who owns upgrades, and how fast can they roll them out?
- What latency and resource budget does the path have?
| Concern | Placement | Owner |
|---|---|---|
| mTLS, cert rotation | Sidecar | Platform/security |
| Workload identity | Sidecar | Security |
| Coarse traffic policy | Sidecar | Platform |
| Baseline L7 metrics | Sidecar | Platform/SRE |
| Idempotency-aware retries | Library | Service team |
| Deadlines/propagation | Library | Service team |
| Business-aware telemetry | Library | Service team |
| Trace context forwarding | Library | Platform (shared lib) |
| Fallbacks | Library | Service team |
| Feature flags | Library | Product engineering |
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to sidecar vs library microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.