Skip to main content

Proxy Routing vs Application-Level Sharding: Decision Guide

This walkthrough helps you decide where shard-routing logic should live — in a dedicated proxy tier, one of the proxy routing architectures covered in this section, or inside your services as application-level sharding logic — as part of the wider cross-partition querying & aggregation strategies guide. Both models answer the same question (“which shard holds tenant 4217?”), but they distribute the costs differently: a proxy (ProxySQL, pgcat, HAProxy) buys application transparency and a single authoritative shard map at the price of an extra network hop and a new failure domain; in-process routing buys minimal latency and no new infrastructure at the price of re-implementing the map in every language and coupling map changes to deploys.

What you need to know first

Gather these facts about your estate — the decision table in Step 5 keys off them:


Proxy tier vs application-level shard routing Top: three services send queries to a single proxy tier that holds the central shard map and forwards each query to one of three shards, adding one hop. Bottom: each of the three services embeds its own copy of the shard map and opens direct connections to all three shards, producing an N by M connection mesh with no extra hop. Model A — proxy tier owns the shard map service A service B service C pgcat / ProxySQL central shard map shard 1 shard 2 shard 3 +1 hop, new failure domain Model B — each service embeds the shard map service A map v12 (deployed) service B map v12 (deployed) service C map v11 (stale!) shard 1 shard 2 shard 3 no extra hop, but N × M connections and per-service map copies

Step 1 — Map the latency and failure domains

Start with the physics. A proxy adds one round trip per query: budget 0.1–0.5 ms when the proxy runs as a sidecar or on the same host, 0.5–2 ms cross-zone. A request that issues five sequential queries therefore pays 0.5–10 ms extra — negligible for a 200 ms page render, decisive for a 5 ms trading path. In-process routing adds effectively nothing: the shard lookup is an in-memory hash or dictionary probe measured in microseconds.

Failure domains cut the other way. With application-level sharding, routing fails only if the application fails — there is nothing new to break, but there are N copies of routing code that can each break differently. A proxy tier is one new thing that can break for everyone at once: a bad config reload, a connection-table exhaustion, or an undersized instance turns into a total outage rather than a partial one. Treat the proxy as tier-0 infrastructure from day one:

# Minimum viable proxy tier: never one instance
proxy_tier:
  instances: 3            # spread across availability zones
  fronted_by: keepalived  # or an NLB / DNS with health checks
  health_check: "SELECT 1"
  drain_timeout_s: 30     # finish in-flight txns before restart
  alerting:
    - proxy_up < instances            # instance loss
    - client_conn_used / max > 0.8    # connection saturation
    - backend_error_rate > 0.1/s      # shard connectivity

Operational note: Proxies also remove a failure mode: they pool connections, so 400 application pods no longer hold 400 × pool_size direct connections against each shard’s max_connections. If you are already near that ceiling, the proxy pays for its hop immediately.

SRE tip: Measure the hop yourself before deciding — run your real workload through a single proxy instance in staging and diff the p50/p99 histograms. Teams routinely discover the pooling benefit makes the proxied path faster at p99 because connection storms disappear.

Step 2 — Compare shard-map update mechanics

The shard map changes every time you split a hot shard, add capacity, or migrate a tenant. How that change reaches running traffic is the sharpest operational difference between the models.

Rolling out a shard map change under each model Under proxy routing, the map lives in the proxy configuration: one reload updates every client at once, with a brief window where old and new connections disagree. Under application-level routing, the map lives in each service, so the change is a deploy per service and the fleet holds mixed versions for as long as the rollout takes. Proxy tier owns the map Every service owns the map old map v1 everywhere reload ~1 s new map v2 everywhere v1 everywhere mixed v1 / v2 across services v2 everywhere deploy window: minutes to hours This single difference decides most architectures: if you reshard often, the proxy's atomic reload is worth its latency; if the topology is stable for years, application routing removes a tier you would otherwise have to keep alive.

With a proxy, the map is runtime configuration, updated centrally without touching applications. ProxySQL routes by query rule and applies changes with a runtime load:

-- ProxySQL admin interface: move tenant range to new hostgroup 30
UPDATE mysql_query_rules
SET destination_hostgroup = 30
WHERE rule_id = 12;   -- rule matching /* shard_key: 4000-4999 */ comments

LOAD MYSQL QUERY RULES TO RUNTIME;   -- atomic, in-flight queries unaffected
SAVE MYSQL QUERY RULES TO DISK;

pgcat takes the same shape declaratively — edit the pool definition and SIGHUP:

# pgcat.toml — shard 2 just moved to a new primary
[pools.orders.shards.2]
servers = [["10.0.3.21", 5432, "primary"]]
database = "orders_s2"

With application-level sharding the map is code or config baked into every service, and an update is a coordinated redeploy. The standard mitigation is versioning the map and gating cutover on a flag rather than the deploy itself:

# shard_map.py — deployed to every service that touches the database
SHARD_MAP_VERSION = 12
RANGES = [
    (0,    3999, "postgres://orders-s0.internal:5432/orders"),
    (4000, 7999, "postgres://orders-s1.internal:5432/orders"),
    (8000, 9999, "postgres://orders-s2.internal:5432/orders"),  # new in v12
]

def dsn_for(tenant_id: int) -> str:
    if not flags.enabled("shard_map_v12"):      # cutover gate, not deploy gate
        return legacy_dsn_for(tenant_id)
    for lo, hi, dsn in RANGES:
        if lo <= tenant_id <= hi:
            return dsn
    raise LookupError(f"tenant {tenant_id} not in shard map v{SHARD_MAP_VERSION}")

The dangerous window is mixed versions: during a rolling deploy, pods on map v11 write tenant 8500 to shard 1 while v12 pods write it to shard 2. A proxy has no such window — the runtime load is atomic at one place.

Operational note: Whichever model you choose, store the authoritative map in one system of record (a shard_map table or config service) and have both proxies and services derive from it. Hand-edited copies drift, and drift means rows on the wrong shard.

DBA tip: During tenant moves, enforce correctness below the router: REVOKE INSERT on the moved tenant’s old shard rows via row-level security or a check constraint, so a stale router errors loudly instead of writing silently to the wrong place.

Step 3 — Weigh language count and team structure

Routing logic is code someone must own. In a single-language shop, an in-process router is one well-tested library, and application-level sharding is cheap. In a polyglot organisation, the same logic exists in Python, Go, Java, and PHP — four implementations of hashing, range lookup, retry, and failover that must agree exactly. A one-line difference in hash normalisation (signed vs unsigned, UTF-8 vs bytes) sends the same key to different shards from different services, and the corruption is silent until a customer reports missing data.

Rules of thumb from production estates:

  • 1 language, 1–5 services: embed the router; a proxy is pure overhead.
  • 1 language, many services: embed it as a versioned internal library with a conformance test suite (golden file of key → shard pairs every release must reproduce).
  • 2+ languages: centralise. Either a proxy tier, or a thin internal “data access” gRPC service that owns routing — which is architecturally a proxy you wrote yourself.
  • No team to run a proxy 24/7: do not deploy one. An unowned tier-0 component is worse than duplicated libraries.

SRE tip: The conformance suite matters even with a proxy — batch jobs, migration scripts, and BI extracts often bypass the proxy “just this once”. Publish the golden key → shard fixture and make every bypass consumer test against it in CI.

Step 4 — Plan the migration path between models

You are rarely choosing from scratch; you are usually escaping the model you have. Both directions are well-trodden:

App-level → proxy. Point each service’s existing per-shard DSNs at the proxy’s per-shard ports (or pools) instead of the databases. The application keeps computing the shard for now — the proxy initially does only pooling and failover. Then move routing into the proxy one query class at a time (ProxySQL query rules matching comments like /* shard_key: N */ that services already emit), and finally collapse the app’s map to “send everything to the proxy”. Each step is independently reversible.

Proxy → app-level. Ship the routing library to all services behind a flag, run it in shadow mode — compute the shard in-process, still send via the proxy, and emit a metric when the two disagree. After a week at zero disagreements, flip services to direct connections one at a time, and keep the proxy running as the fallback path until connection counts on the shards stabilise.

# Shadow-mode disagreement rate — must be zero before cutover
sum(rate(shard_router_disagreement_total[5m])) by (service)

Operational note: The hybrid endpoint of the first migration — app computes the shard, proxy owns pooling and server topology — is a legitimate permanent architecture, not just a waypoint. Many large estates stop there deliberately.

Step 5 — Apply the decision table

Dimension Proxy tier Application-level Hybrid (app picks shard, proxy pools)
Added latency per query 0.1–2 ms hop ~0 (in-memory lookup) 0.1–2 ms hop
New failure domain Yes — tier-0, needs HA + owner No new infra; N code copies Yes, but routing bugs stay app-side
Shard-map update Central runtime reload, atomic Redeploy or flag flip, mixed-version window Topology central; key-map via deploy
Polyglot cost None — protocol-level Full router per language Thin lookup per language
Connection pooling Built in Per-pod pools; N × M mesh Built in
Query visibility Central metrics, rewrite, mirroring Per-service instrumentation Central at proxy
Transparency to apps Full — apps see one endpoint None — apps are shard-aware Partial
Best fit Polyglot org, frequent resharding Single language, tight latency budget Large estates mid-migration or at scale
Language count versus resharding frequency The vertical axis is how many languages talk to the database; the horizontal axis is how often the shard map changes. One language and a stable map favours application routing. Many languages or frequent resharding favours a proxy. The mixed quadrant — one language but frequent change — is where a shared client library with a versioned map works best. many languages one language map changes yearly map changes monthly or faster Proxy one map for polyglot clients Proxy, clearly atomic reloads, no fleet-wide deploy Application routing no extra tier, no extra hop Shared client library versioned map, fetched at runtime Both models can be right at different times — design the routing seam so moving between quadrants is a swap, not a rewrite.

Scenario recommendations:

  • Polyglot SaaS, tenants migrate between shards monthly: proxy tier. Central map updates and query mirroring during moves outweigh the hop.
  • Single Go monolith plus workers, p99 budget under 10 ms: application-level, shipped as one library with the conformance fixture.
  • 400+ pods brushing against max_connections: at minimum the hybrid — you need the pooler regardless of where routing lives.
  • Two teams, no one owns infra: application-level, even if polyglot; duplicated libraries fail more gracefully than an orphaned proxy.
  • Heavy scatter-gather analytics on top of OLTP routing: either model for OLTP, plus a federation engine for the analytical fan-out — see the cross-partition querying & aggregation strategies overview for that layer.

Failure mode table

Failure mode Root cause SRE mitigation
Total outage when the proxy tier restarts Proxy deployed as a single instance or restarted without draining, killing every in-flight connection for all services Run ≥3 instances behind a health-checked VIP, use drain_timeout before restarts, and canary config reloads on one instance before fleet-wide LOAD ... TO RUNTIME
Rows written to the wrong shard during a tenant move Mixed shard-map versions in a rolling deploy (app-level) or one service bypassing the proxy with hard-coded DSNs Gate cutover on a feature flag rather than the deploy, run shadow-mode disagreement metrics to zero first, and enforce a write fence (RLS or check constraint) on the source shard
p99 latency doubles after adopting a proxy Proxy placed cross-zone from the applications, adding a WAN round trip to every query, or undersized worker threads queueing under load Pin proxy instances to the same zone (or sidecar them), monitor proxy queue depth and client_conn_used, and load-test at 2× peak before cutover

FAQ

How much latency does a proxy hop really add?

A well-placed proxy on the same host or availability zone adds roughly 0.1–0.5 ms per round trip; cross-zone placement adds 0.5–2 ms. For a request issuing five sequential queries, that is 0.5–10 ms of added p50 latency. Connection pooling in the proxy often claws this back on databases where connection establishment is expensive — PostgreSQL especially — so measure end-to-end p99 under your real workload rather than assuming the hop is a net loss.

Can proxy routing and application-level sharding coexist?

Yes, and most migrations pass through exactly this hybrid: applications compute the shard and connect to a per-shard proxy pool, so the app owns key-to-shard mapping while the proxy owns shard-to-server mapping, pooling, and failover. It is a legitimate permanent architecture, not just a transition state. The risk is two shard maps that can disagree — make one system of record authoritative and have the other layer refuse to start on a version mismatch.

Which model handles cross-shard queries better?

Neither routes a single query to multiple shards well. Proxies forward each statement to one backend, and in-app routers must fan out and merge results manually. If scatter-gather queries are a meaningful share of the workload, add a federation layer for those queries — postgres_fdw, Citus, or Trino, covered under federated query execution — and keep the routing tier for single-shard OLTP traffic.