Switching RPC Providers Without Downtime: Cutover and Rollback
The decision is made, the new key is issued, and the only thing left is the change itself — the part that can page someone. To switch RPC providers without downtime you need two plans, not one: the cutover that moves traffic, and the rollback that moves it back. Build the rollback first, and fix its trigger, its owner and its deadline before a single request moves. The half of the move that cannot be reversed is the half you will want to do first.
What actually breaks when the URL changes
A JSON-RPC endpoint is a URL plus a key. For stateless reads, swapping it is a config change, and that is worth saying plainly because it is true:
- RPC_URL=https://rpc.old-provider.example/v1/$OLD_KEY
+ RPC_URL=https://rpc.new-provider.example/v1/$NEW_KEY
Everything that makes a cutover risky sits outside that line.
The five things that do not move with the URL
- WebSocket subscriptions. A subscription is coupled to the connection that created it, and closing that connection removes every subscription created over it [1]. The id it returns is the handle
eth_unsubscribetakes [2], so a subscription opened against a new endpoint is a different subscription with its own id. - In-flight writes and the nonce state around them. A signed transaction carries a fixed nonce, and inclusion advances the sender's nonce [3]. Transactions already broadcast to the old endpoint's pool sit in a state the new endpoint cannot see.
- Access control. IP and origin restrictions are per-key: a request must come from a listed IP or carry a listed Origin header [4]. They have to exist on the new side before traffic arrives.
- Method and archive coverage. A method the endpoint does not implement answers with -32601 [5]. State at a past block may be pruned, which surfaces as a missing-trie-node response rather than as a missing method [6].
- The plan behind the key. All keys on an account share the plan's rate limits [7], so a new key does not get its own allowance. The limit belongs to the plan, and how a server counts requests behind a 429 is implementation-defined [8].
What 'no downtime' has to mean for you
Set the target in your own terms: no failed user-facing request, no gap in the indexer, no transaction sent twice. Then accept the asymmetry. Zero failed requests is achievable for reads. It is not achievable for writes, because a write that has been included cannot be un-included. That asymmetry is why the write path moves last and why a rollback has a boundary at all.
Build the rollback before you build the cutover
Write the trigger before you write the plan
A rollback trigger is a condition, not a feeling. Give it three parts: a named metric, a threshold and a window. If the error rate on eth_call exceeds X for Y minutes. If the indexer's head falls more than N blocks behind. The numbers are yours to choose, and choosing them is the work. The point is that they are chosen in advance, by someone who is not currently being paged. Name the owner, and name the second person who can call it without asking.
The rollback is not symmetric
Reads roll back by pointing the URL at the old endpoint again. The state did not change, so the old endpoint is still correct. Writes do not roll back. A signed transaction carries a fixed nonce, and inclusion advances the sender's nonce, so the same signed transaction cannot be included twice [3]. That is reassuring against a double spend and useless as a rollback, because the effect already happened. The rollback boundary is the write path, which is why writes move last.
Put a clock on it
A rollback with no deadline becomes a second migration. Decide how long the old endpoint stays provisioned, funded and allowlisted after the cutover, and what happens when that window closes. The old key is the rollback. Cancelling it is the point of no return, and it should be a deliberate act with a date on it.
The cutover sequence: how to switch RPC providers without downtime
| Step | What moves | Reversible? | Exit condition |
|---|---|---|---|
| 0 | Nothing | Yes | Every method in your inventory has answered once from the new endpoint |
| 1 | Reads | Yes | Read error rate matches the old endpoint over a full traffic cycle |
| 2 | Subscriptions | No — the gap must be backfilled | No gap between the last old message and the first new one, checked by block number |
| 3 | Writes | No | The first transactions through the new path are included and their receipts match |
Step 0: provision and verify the new endpoint before any traffic moves
Get the key, set the allowlist and origin restrictions [4], and make one successful call per method you actually use — not one call total. Confirm the methods you depend on are implemented; a missing one answers with -32601 [5]. Confirm the archive depth you need is there; pruned state answers as a missing trie node [6]. Check a block range you already serve, because the cap belongs to the node that answers and not to the chain, so a range the old endpoint accepted can be rejected by the new one [9]. Subscriptions can be refused outright on a plan that does not include them [10]. The getting-started guide covers the key and the first request.
Exit condition: every method in your inventory has answered once from the new endpoint.
Step 1: move reads, one method class at a time
Move the deterministic reads first — eth_chainId, eth_getCode at a fixed block, eth_getTransactionReceipt by hash — because a wrong answer there is unambiguous. Then the reads with tolerance. Leave the time-dependent reads for last within this step, since they will look different for reasons that are not the new endpoint's fault. Comparing two providers on your own traffic is what tells you which class each method is in.
Exit condition: the read error rate on the new endpoint matches the old one over a full traffic cycle, compared at a pinned height or a tag that resolves to one rather than at the tip. safe and finalized are distinct guarantees [11], and an unresolvable tag is answered as an unknown block [12] — a spec-permitted difference you classify, not a fault you escalate.
Step 2: move subscriptions
Subscriptions are the step people skip and then discover as a data gap a day later. The geth documentation puts the binding plainly: "Subscriptions are coupled to a connection. If the connection is closed all subscriptions that are created over this connection are removed." [1] (read 2026-11-06). eth_subscribe is only available on transports that support server-initiated notifications [13], so a subscription is stateful on a duplex transport rather than a stateless request. Notifications cover current events and not past ones [14], so the hole between the last message from the old subscription and the first from the new one is not replayed.
So: open the new subscription first, confirm it is delivering, then close the old one, and backfill the overlap by block range rather than assuming there is none. Confirming delivery is not a formality. A subscription can be acknowledged and then deliver nothing, with no error at all [15].
{"jsonrpc":"2.0","id":1,"method":"eth_subscribe","params":["newHeads"]}
{"jsonrpc":"2.0","id":2,"method":"eth_unsubscribe","params":["0x<old-subscription-id>"]}
Exit condition: no gap between the last old message and the first new one, verified by block number.
Step 3: move the write path last, and only with a plan for the nonce
Writes move last because they are the part that does not roll back. Before moving them, stop new broadcasts, let the in-flight set drain, and confirm the nonce the new endpoint reports for each sending account matches what you expect. Then move.
Broadcasting to both endpoints is a duplicate submission, not a safety net. The second endpoint accepts the transaction into its own pool because it has not seen it. What stops it becoming a second copy is the nonce rule, not the pool: a signed transaction carries a fixed nonce, and inclusion advances the sender's nonce, so it can be included only once [3]. The already known rejection is what you get when you resubmit to the same node, because that check is a lookup in that node's own pool [16] [17].
Exit condition: the first transactions through the new path are included and their receipts match what you expected.
Running the rollback
What rolls back cleanly
Reads, and only reads. Point the URL back, confirm the old endpoint is still at the tip and still serving the methods you moved, and watch the error rate return to baseline. Two things can bite here. The old endpoint's rate limit is the one you were on before, and the counting rule behind a 429 is implementation-defined [8], so the rollback can fail for a reason that has nothing to do with the new provider. Retry-After is permitted and not required [18], so do not build the retry on the header being there. Confirm too that the old endpoint still resolves the tags you moved, since an unresolvable tag is answered as an unknown block [12].
What does not
Anything already written. Transactions included through the new path stay included, and a nonce consumed there is consumed [3]. Subscriptions you closed have a gap that has to be backfilled whichever endpoint you end up on, because notifications are not replayed for past events [14]. And any state your own system wrote on the strength of a response from the new endpoint — an indexer row, a cached balance — is now suspect and needs the treatment you would give a reorg: identify the range, re-read it, rewrite it. A pruned-state response is a recorded failure mode [6], so a response you acted on can be one you should not have. This is not easy. It is bounded, which is a different thing.
After the rollback
A rollback is not a failure of the migration, it is the migration working. What it produces is a specific list of what the new provider could not do, which is the input to either a second attempt or a different decision. Keep the old endpoint provisioned until that list is empty or accepted.
When to stop rolling back and fix forward instead
Roll back when the failure is in a class you cannot fix inside the window: a method that is not implemented, state that is not kept, a limit you cannot work within. Fix forward when the failure is a difference you already classified before the cutover and decided to accept. Rolling back for a known difference wastes the window and teaches the team to distrust the trigger.
The failure mode is a rollback that is never called off. If the old endpoint is still carrying traffic a month later, you did not migrate — you added a provider. That is a legitimate outcome, but it is a different decision, and it should be made deliberately rather than by drift.
Where this leaves you
Write the trigger, provision the new endpoint, move reads, move subscriptions, move writes, and keep the old key until the list of differences is empty or accepted. We would rather you kept the old key a week too long than a day too short.
Sources
- Real-time Events — Considerations: subscriptions are coupled to a connection — Go-Ethereum (geth documentation), 2023-08-16
- execution-apis/src/eth/subscribe.yaml — eth_subscribe description, subscription ID — ethereum/execution-apis (GitHub), 2021-06-16
- execution-specs — src/ethereum/forks/cancun/fork.py, check_transaction() — ethereum/execution-specs (GitHub), 2020-08-11
- SwiftNodes /docs/authentication — Restricting a key to IPs or origins — SwiftNodes, 2026-01-01
- JSON-RPC 2.0 Specification — error code table — jsonrpc.org (JSON-RPC Working Group), 2010-01-01
- SwiftNodes /errors — missing-trie-node, heading states the cause — SwiftNodes, 2026-09-23
- SwiftNodes /docs/authentication — API Key Management, keys share plan limits — SwiftNodes, 2026-01-01
- RFC 6585 — Section 4, scope limit on 429 — IETF (RFC 6585, Standards Track), 2012-04-01
- SwiftNodes /errors — eth-getlogs-block-range-too-large, the cap is a property of the node — SwiftNodes, 2026-09-24
- SwiftNodes /errors — eth-subscribe-not-available, observed message text — SwiftNodes, 2026-09-24
- execution-apis/src/schemas/block.yaml — BlockTag schema description — ethereum/execution-apis (GitHub), 2021-06-16
- execution-apis/src/schemas/block.yaml — BlockTag, unresolvable tag — ethereum/execution-apis (GitHub), 2021-06-16
- execution-apis/src/eth/subscribe.yaml — eth_subscribe, transport requirement — ethereum/execution-apis (GitHub), 2021-06-16
- Real-time Events — Considerations: notifications are not replayed for past events — Go-Ethereum (geth documentation), 2023-08-16
- SwiftNodes /errors — eth-subscribe-not-available, acknowledged subscription with no notifications — SwiftNodes, 2026-09-24
- go-ethereum — core/txpool/errors.go — ethereum/go-ethereum (GitHub), 2013-12-26
- go-ethereum — core/txpool/legacypool/legacypool.go, add() — ethereum/go-ethereum (GitHub), 2013-12-26
- RFC 6585 — Additional HTTP Status Codes, Section 4 "429 Too Many Requests" — IETF (RFC 6585, Standards Track), 2012-04-01
This article was researched, written and edited by AI under human supervision, and checked against Rankwrite's public editorial rubric.
John Sullivan covers RPC infrastructure, node operations, and multi-chain development at SwiftNodes — what it actually takes to keep endpoints fast, fresh, and reliable across EVM and non-EVM networks.
Related posts
- Arbitrum WebSocket Gotchas We Hit in Production
Connecting to Arbitrum over WebSocket the same way you connect to Ethereum mainnet will bite you. The sequencer has no public mempool, newHeads is a firehose, and reconnecting isn't enough. Here's what actually breaks and how to handle it.
- Solana WebSocket vs HTTP RPC for Bots
Most Solana bots burn 80% of their RPC budget polling for state that WebSocket subscriptions would push to them for free. Here's when to use which, with the commitment-level gotchas that bite people in production.
- Shadow Traffic: comparing two RPC providers before you switch
Compare two RPC providers by mirroring your own read traffic to both at one block height, diffing the responses to see what your app would have failed on.