Ten Bounds on Failure: Resilient Payment Systems at Shopify

A Staff developer on Shopify's payments team distills five years of running payment infrastructure into ten practices. Bound every wait (timeouts, circuit breakers, admission control), make every retry safe (idempotency keys with recovery steps), and verify the money afterward (reconciliation with recorded anomalies). All of it is wrapped in a loop of golden-signal monitoring, correlated logs, flash-sale load tests, and a disciplined incident process. The post has no single architecture to unveil. Its value is the dependency chain between small mechanisms, each one bounding a failure mode the previous one lets through.

Interactive

Drive a payment worker pool past its saturation knee, then flip on each defense — five-second timeouts, a circuit breaker, a checkout throttle — and watch which failure each one actually bounds.

Open the visualization ↓

Problem

The post is unusual for an engineering blog. It is not the story of one system, but a Staff developer's distillation of five years on Shopify's payment infrastructure into the ten practices he teaches new team members. Bart de Water writes that what was mostly consistent among engineers joining the payments team, whatever their language or background, was little experience building systems at Shopify's scale. The post is the onboarding curriculum for that gap, boiled down from an internal team presentation.

The environment those practices exist for has two defining properties. The first is that payment processing lives at the boundary between Shopify and its financial partners, across networks that are unreliable even when they look reliable most of the time. At Shopify's scale, a once-in-a-million chance of something unreliable during payment processing means it happens many times a day. And in payments, an ambiguous outcome is not an inconvenience. If an API call to a partner times out, the request must be retried, but a careless retry double-charges the card. That is not just annoying for the cardholder: it exposes the merchant to a chargeback if the duplicate goes unnoticed, and a double refund harms the merchant directly. De Water frames the stakes with a Dutch saying, trust arrives on foot but leaves on horseback, and notes that merchants' livelihoods depend on Shopify Payments when they choose it.

A NETWORK CALL FAILS THREE WAYS
Three ways a network call fails; only the first is safe to retry
Only the first failure is safe to retry. When a call dies mid-work or loses its response on the way back, the client cannot tell success from failure - which is why every retry needs an idempotency key.

The second property is load, and the post grounds it in queueing theory. Little's Law says the average number of requests in a system equals the arrival rate times the average time each spends in it, which rearranges into capacity = throughput times latency. So 50 requests handled at 100 milliseconds each is a throughput of 500 per second. When incoming work exceeds capacity, the queue grows until clients time out waiting - at which point, from the client's side, the service is down. The danger starts well before full utilization: processing time is uneven, so the queue starts growing around the 70 to 80 percent saturation mark, and with enough volume servers can run out of memory holding queued work and crash. The platform defaults make it worse. Ruby's built-in Net::HTTP allows 60 seconds each to open, write, and read, while Go's http.Client and Node's http.request ship with no default timeout at all - an unresponsive server can tie up resources indefinitely.

Solution

The ten tips look like a checklist, but they actually build on each other, each one catching a failure the one before it lets slip. Bound how long anything can wait, stop calling a service that is already down, limit how much work you let in, make the retries safe, and check the money records afterward. The rest - monitoring, logging, load testing, incidents, retrospectives - is the verification loop around that chain.

The first bound is time, and de Water calls it the one thing to keep above all: set low timeouts everywhere you can. Shopify sets them in HTTP clients and data stores (MySQL's MAX_EXECUTION_TIME caps a query, with pt-kill to catch bad ones). The right value comes from monitoring, but the post's starting point is a one-second connect timeout with five seconds to read - because who waits over five seconds for a page to load or error?

HOW LONG WILL YOUR CLIENT WAIT
Default HTTP client timeouts compared with Shopify's one and five second caps
Left unset, an HTTP client can wait forever, and Ruby's Net::HTTP waits a full minute. Shopify caps every call instead - one second to connect, five to read - because who waits over five seconds for a page?

But a timeout still burns a full wait per attempt, and a service that is down tends to stay down. A circuit breaker closes that gap: after several timeouts in a short window, it opens and calls fail instantly instead of trying at all. Shopify built Semian to wrap its Net::HTTP, MySQL, Redis, and gRPC calls; failing fast the moment a service looks down saves the resources the next timeout would waste, and the rescue is where a fallback can live. The detail that shows real care is how finely the breaker is scoped. A single global payment endpoint often hands off to a different local bank in each country behind the scenes. So Shopify labels each breaker with the merchant's country: an outage in one country then trips only that country's breaker, and everyone else keeps taking payments. Breakers are no silver bullet: they demand knowing how the app fails and what a fallback means, and a misconfigured one still wastes money.

The third bound is admission: no system can out-scale the world, so incoming work has to be capped (rate limiting and load shedding). Shopify's sharpest tool is a scriptable load balancer that throttles how many checkouts run at once; buyers beyond capacity wait in a queue before they can pay. Whether the caps hold is tested by simulating flash sales against benchmark stores. There is a catch - partners' test environments don't match production's capacity or latency - so the benchmark store talks to a benchmark gateway built to mimic them.

Everything above makes requests fail faster and more often on purpose, which is only safe because retries are safe. Shopify's payment service tracks each attempt (one or more retried requests) under an idempotency key. The key records which steps already ran, such as writing the local transaction record, so only one request ever reaches the financial partner. If a step failed and a retry arrives under the same key, recovery steps rebuild the state first. A key stays unique only as long as the request should be retryable, usually 24 hours. Even the key format is a scaling choice: Shopify uses ULIDs (a 48-bit timestamp plus 80 bits of randomness) over random UUIDv4, because sortable keys fit the b-tree structure databases index on. In one busy system, that single change cut the time to write a row in half.

The last line concedes prevention is never complete. Reconciliation checks Shopify's records against its partners' - individual charges and refunds, and aggregates like a merchant's unpaid balance, which also feed the tax forms Shopify files. A mismatch is logged as an anomaly, then fixed automatically where possible or investigated by the team where not. The stance is deliberate: record every mismatch to know what happened, but treat anomalies as a last resort and prefer preventing them.

The verification loop starts with Google's four golden signals: latency, traffic, errors, saturation. Two payments twists apply. First, a declined card is a normal outcome, not a system error the way an HTTP 500 from a partner is, so the two are counted separately. Second, because breakers make failed calls return almost instantly, a plain latency graph looks deceptively healthy unless successes and failures are charted apart. Structured logs carry one correlation identifier across the whole flow, so one attempt's story is searchable end to end. When something breaks, an incident runs with three roles - a coordinator, a support lead on comms, the service owners restoring service - and a retrospective that week digs into what happened and what will prevent it. Those writeups, public to all staff, train the next on-call engineer.

50%
decrease in INSERT duration after switching idempotency keys from UUIDv4 to ULID

Tradeoffs

  • Aggressive timeouts turn slowness into failure on purpose. A five-second ceiling means a legitimately slow partner response that would have succeeded at second eight is cut off, counted as a failure, and retried. The buyer sees an error or a longer wait, and the partner gets duplicate traffic at exactly the moment it is struggling. What Shopify buys is that slow can never quietly become down: no worker is stuck for a full minute on Ruby's defaults, so the queue can't grow without limit. The catch is that cutting requests off early is only safe because idempotency keys make the retries safe; adopted alone, low timeouts turn partner slowness into double-charge risk.
  • Circuit breakers add their own failure modes and their own tuning burden. The post is explicit that Semian is not a silver bullet: a breaker only helps if the team understands how the app fails and designs what falling back means, and a misconfigured breaker can still waste a lot of money. Granularity is a standing design problem with costs on both sides. Identifiers too coarse turn one country's local outage into a global payments stop, which is why Shopify scopes them by merchant country code; but finer identifiers multiply the breaker state that has to be monitored and tuned. Breakers also make the dashboards lie: once failing calls return almost instantly, an average-latency graph looks great even while everything is failing, unless successes and failures are charted separately.
  • The idempotency machinery bounds its own protection. Keys are unique only for the window a request should be retryable, usually 24 hours or less, so a retry arriving after that window is no longer covered. The window length is a real dial, trading protection duration against the cost of tracking attempts. The ULID choice trades a property away too: the 50% INSERT improvement comes from embedding a 48-bit timestamp, which makes keys time-ordered and means they carry timing information a random UUIDv4 does not. That is a fine trade for internal attempt tracking, and a thing to watch anywhere keys leak into contexts where predictability matters.
  • Reconciliation is a standing admission that prevention will never be complete. The anomaly records, their automatic fixes, and the hands-on investigation of the cases automation can't handle are a job that never ends. It is a permanent team cost, paid in exchange for money records accurate enough to put on the tax forms Shopify files for merchants. The post names the cultural risk in its own stance: anomalies must stay a last resort, because a team that leans on after-the-fact repair loses the pressure to prevent the mismatches in the first place.
  • Admission control protects the system by making customers wait at the worst possible moment. The checkout throttle puts buyers in a waiting queue precisely during flash sales, the events where a merchant's revenue peaks, encoding a business tradeoff between conversion and survival into an engineering artifact. And the confidence that these limits hold has a boundary: because financial partners' test environments don't match production's capacity or latency, Shopify tests against a benchmark gateway that mimics them. So end-to-end confidence stops at a simulation of the one dependency the team does not control.

Patterns in this article

  • Idempotency Keys

    Shopify's instantiation is provider-side: the centralized payment service tracks each attempt's completed steps under the key and runs recovery steps to rebuild state before continuing, so a retry never re-reaches the financial partner. The ULID key format makes the key itself index-friendly - a 50% INSERT duration reduction in one high-throughput system.

  • Circuit Breaker

    Semian wraps Net::HTTP, MySQL, Redis, and gRPC. The notable refinement is identifier granularity: breaker state is scoped to endpoint plus merchant country code, so an outage at one country's local acquirer trips only that country's circuit.

  • Fault Isolation

    The country-scoped circuit breaker identifiers are failure-domain partitioning at the granularity of a payment gateway's local acquirers - one region's outage is contained instead of propagating through a shared global circuit.

Also solving this

Other systems in behindscale's Ambiguous failure under retry class: