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.

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

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.