Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
  1. Home/
  2. Writing/
  3. Sync engines
Sync engines

The reconnect storm problem

When 800 phones come back online at once, what happens to your server. The patterns that survive.

April 12, 2026·7 min·by Ali Yosef

Loading essay…

ShareXLinkedInRSS
Get the next essay in your inbox.New posts every Sunday morning. Engineering, in plain language. Free, unsubscribe anytime.Subscribe→
AY
Ali Haitham Yosef

Engineer and writer. Builder of multi-tenant platforms, sync engines, and quietly reliable systems. 600+ shipped projects since 2015.

SubscribeStart a project→

Read next

  • PB
    May 5, 2026·Sync engines

    When polling beats websockets

    A year of trade-offs in Mohaseb. Why the obvious answer was wrong on our network conditions.

    8 min
  • CR
    Apr 28, 2026·Sync engines

    Conflict resolution in offline-first apps

    Last-write-wins is almost always wrong. Here is what works in practice when two clients disagree.

    10 min
  • SE
    Series
    Apr 20, 2026·Sync engines

    Building a sync engine, part one: the queue

    Series opener. The shape of an outgoing queue, why ordering matters, and the test harness that catches everything.

    14 min
←Back to all essays

A reconnect storm is what happens when many devices come back online at the same moment. Power outage in a city, a router reboot at a chain restaurant, a tower handover that resets thousands of cellular sessions: all of them produce the same shape. A flood of clients, each carrying a queue, each opening a websocket, each issuing the same first request. The server, which was idle a second ago, is now under more load than it sees in a normal week.

This essay is about what we did the first time it broke us, and the patterns that worked.

The shape of the storm#

It is worth being precise about what is happening. The clients are not malicious; they are doing what the docs told them to do. On reconnect:

  1. Open a fresh websocket.
  2. Authenticate.
  3. Pull the deltas they missed while offline (one HTTP request per entity type, in our case originally 48 of them).
  4. Push their queued writes (one request per item, or a small batch).
  5. Subscribe to per-tenant channels for live updates.

In normal operation these steps are spread across users and time, so the server sees a steady trickle. In a storm, a few thousand devices fire all five steps within the same five-second window. The server spends those five seconds servicing handshakes; the database spends them servicing 50,000 small queries with high lock contention; the websocket layer spends them spawning 5,000 new connections. The slowest step gates the whole thing.

The first time it happened to us, the server stayed up but the queries queued; clients started timing out, started retrying, and the retries made the storm worse. By minute three we were watching the queue grow faster than we could drain it. By minute five we hand-rolled a kill switch.

Five fixes, in the order that mattered#

We did not get them all in one go. We got the first one in panic that afternoon and the rest over the following month.

1. Jittered reconnect, on the client#

The single biggest win. Every client, on losing connection, picks a random delay in [0, jitter_max] before its first reconnect attempt; on subsequent failures the delay grows exponentially with another small jitter. This breaks the synchronisation. A thousand clients no longer reconnect at second 0; they spread across [0, 30], and the server sees a slope instead of a wall.

Duration nextReconnectDelay({required int attempt}) {
  final base = math.min(30000, 500 * math.pow(1.7, attempt).toInt());
  final jitterMs = math.Random().nextInt(base ~/ 4);
  return Duration(milliseconds: base + jitterMs);
}

Note: jitter has to be multiplicative-ish, not additive. Adding ±200 ms to a 30 s delay barely smooths the curve; multiplying or adding a noticeable fraction does.

2. One unified pull, not 48#

We had inherited per-entity sync endpoints (/products/list, /orders/list, /contacts/list, …). On reconnect a single client issued 48 of these. A unified pull endpoint that takes a since cursor across all entity types in one request collapses 48 requests into 1. Fewer connections, fewer queries, less ceremony.

POST /api/v1/sync/pull
{
  "cursors": {
    "contacts":     "2026-05-08T14:22:01.123Z",
    "invoices":     "2026-05-08T14:22:01.500Z",
    "products":     "2026-05-08T14:22:00.000Z"
  },
  "limits": { "perEntity": 200 }
}

The server returns a single bundle keyed by entity type. The client walks the bundle and applies in dependency order (contacts before invoices, products before invoice items, etc).

3. Per-tenant queues with fairness#

If one tenant is misbehaving (or has a thousand devices), they should not starve the others. Instead of a single global Horizon queue, we shard by tenant and round-robin between active tenants. A noisy tenant takes longer to drain its own queue; its noise does not bleed across.

The trade-off is that you cannot use Horizon's autoscaling per "workload" any more, since "workload" is now per tenant. Worth it.

4. A circuit breaker on the cold path#

The pull endpoint is the cold path. If it starts failing more than ~5 % over a sliding minute, we trip a breaker on the server side and return a polite "try again in 30 seconds" with a Retry-After header. The clients honour it. The server gets its breath back. Without the breaker, every failed pull triggers a retry, the retry adds load, the load triggers more failures.

Breakers belong on the server, not the client

A client-side circuit breaker is fine, but the server-side breaker is the one that protects the database. When the database is stressed, the only way to make the storm stop is for the server to refuse work, not for the client to volunteer to wait.

5. Connection-pool guardrails#

The websocket layer (Reverb in our case, but it could be any) has a finite per-process connection budget. Without limits, a storm fills the pool, new connections get refused at the OS level, and the error message at the client end says "Connection refused" — useless. Setting explicit per-tenant connection caps and per-process maximums means the server starts shedding cleanly, with a header that tells the client why.

What we measure now#

The dashboard for storm health is small:

MetricHealthyWarning
Reconnects per second< 50/s> 200/s
Pull p99 latency< 800 ms> 3 s
Queue drain rate> 1×in-flow< 0.7×in-flow
Breaker open events / hour0≥ 1
Pool utilisation< 60 %> 85 %

The interesting one is "queue drain rate ÷ in-flow rate." If it is below 1, you are losing ground. If it stays below 1 for more than a minute or two, the storm is winning. The kill switch trips somewhere around 0.6.

Why this is worth getting right#

The first time we had a storm, the team got pulled into an unplanned incident on a Tuesday morning that ended up costing about six engineer hours and a panicked apology to one customer. The five fixes above took roughly two weeks total to design, ship, and validate. The next storm, the system handled itself and we found out about it from the dashboard, not the customer. That is the trade you are buying.

If sync engines are interesting, the series page picks up where this ends.