Preparing today's journal

Please wait a moment.

Back to stories

Connected Did Not Mean Synced — Free-Tier Limits, an Older iPhone, and Missed Realtime Events

How a free-tier transfer limit, an older iPhone, and missed Realtime events led us to separate storage, signals, and recovery.

Read like a book
Normal
A white recovery path bridging a broken blue thread beside an older smartphone with a black screen, with small glowing nodes connected around the workbench

Topic

Haru Space Lab

Connected Did Not Mean Synced — Free-Tier Limits, an Older iPhone, and Missed Realtime Events

How a free-tier transfer limit, an older iPhone, and missed Realtime events led us to separate storage, signals, and recovery.

Summary

Summary

  1. A free-tier transfer limit at the time prompted us to separate the roles of PostgreSQL, Blob storage, and Realtime while reducing repeated full-state reads.
  2. We stopped treating subscription as synchronization, layering explicit auth, a post-subscription resync, and lightweight version checks to recover missed events.
  3. For an older iPhone outside the official support range, we reduced animation and left a static recovery path instead of promising full compatibility.
12Page
A white recovery path bridging a broken blue thread beside an older smartphone with a black screen, with small glowing nodes connected around the workbench

Summary

At a glance

  • A free-tier transfer limit at the time prompted us to separate the roles of PostgreSQL, Blob storage, and Realtime while reducing repeated full-state reads.
  • We stopped treating subscription as synchronization, layering explicit auth, a post-subscription resync, and lightweight version checks to recover missed events.
  • For an older iPhone outside the official support range, we reduced animation and left a static recovery path instead of promising full compatibility.

This retrospective is based on Git history and design documents from July 13–14, 2026. The free-tier limit discussed here is historical context for understanding the incident at that time; it is not guidance about the service’s current pricing or included usage.

The screen said we were connected. But the stone the other person had played never appeared on my board.

Only after a refresh did the new state show up. If the connection itself had been severed, finding the cause might have been easier. The problem was that both the browser and the real-time channel appeared “healthy” while two people were looking at different scenes.

During those two days, Haru Space ran into several problems that seemed unrelated. We reached the network transfer limit of the database free plan we were using at the time. An older iPhone stayed on its initial loading screen forever. And in a real-time game, there were moments when events went missing even after a subscription had succeeded.

At first, I thought they were three separate failures. Looking back, all three were asking the same question.

Does a successful connection alone mean we can say that the person is seeing the latest state?

The answer was no. So instead of chasing a faster connection, we began building a system that could bring itself back into alignment when the connection was wrong.

A free-tier limit revealed the anatomy of the system

Our records from that period show an incident caused by exceeding the free plan’s network transfer limit. This is not an account of today’s pricing rules. It was the event that first made us look closely at what the service was moving and how often it was moving it at that point in time.

The first suspect was the combination of periodic chat reads and media data. The screen asked the server at short intervals whether a new message had arrived, and large thumbnail strings stored directly in the database in the past could be included in list responses. A person was waiting for one new message, while the system repeatedly moved heavy data it had already seen.

At first, it seemed enough to lengthen the polling interval. Adjusting that interval was indeed necessary. But that alone would have allowed the same problem to reappear in another feature. The root cause was closer to “putting different responsibilities through one channel” than simply “reading too often.”

We divided the responsibilities of storage and delivery again.

  • PostgreSQL would hold sources of truth that must be readable again, such as messages, memberships, and game state.
  • Blob storage would hold large files such as images and videos, while the database would keep only their locations and required metadata.
  • Realtime would not replace the source of truth; it would quickly deliver a small signal that “something changed.”
  • The API would provide a path to verify authorization and reread the latest source of truth after a signal arrived or a connection recovered.

This separation was not about arranging technology names neatly. It was a policy that decided cost and recovery at the same time. Removing large files from database responses reduced repeated transfer, and refusing to trust events as permanent data meant that a missed event could still be reconciled against the source of truth.

That does not mean this one change eliminated transfer problems forever. As usage grows and features change, bottlenecks move too. What we gained was not a “problem solved” stamp, but an anatomical map that let us explain which storage layer created which cost and what we needed to observe.

We reduced polling without treating Realtime as absolute truth

Periodically fetching the entire state is simple. Even if an event is missed, the next read brings the client back in line. But the system keeps reading when nothing has changed, and as usage grows, the same cost repeats. Realtime looked much more efficient because it could react only when something changed.

We therefore began moving chat and game updates toward real-time events. When a new message or game move was stored, the system sent a small event, and the receiver fetched only the state it needed. Events arriving in quick succession were briefly grouped to reduce duplicate reads. When the page was not visible, we paused the fallback reads and reconciled once when it became visible again.

So far, this looked like a conventional optimization. The problem was that we had waited too long to take Realtime’s own gaps seriously.

State can change after the initial data is read but before the subscription is ready. A channel can wobble briefly while a mobile network moves between Wi-Fi and cellular. An event can be delayed or lost at the boundary where an authentication token is refreshed. When a browser returns from the background, the connection indicator and the actual state can be out of step.

If we saw only the subscription success callback and declared “synchronization complete,” we created a silent failure. The connection indicator showed no problem and the logs showed no error, but people could be looking at different game boards or chat lists.

Subscribed meant ready to receive future signals; it did not prove that the state up to that point was the same.

We made authentication explicit before subscribing

A real-time channel could not be allowed to cross an authorization boundary merely because it was fast. In Haru Space, being signed in was not enough. The server also had to verify that a person belonged to the currently selected group and room, and that they were actually a participant in the game.

While reviewing the initial implementation, we fixed the order: explicitly apply authentication information to the client, then subscribe to the channel. For chat, we checked the sign-in session and room membership. For games, we separately checked group membership and actual participation. Only after those checks did the server issue short-lived authentication information scoped to that room or game. The browser applied it first, then opened only the one channel it needed. When the person left the room or game, the previous channel was released.

That order mattered for two reasons.

First, it separated connection information that could be public from secrets that only the server should hold. Second, it prevented us from treating “knows the channel name” and “has permission to receive its data” as the same thing.

The design notes from that period also record a direction of combining database policies with membership checks. I do not want to exaggerate and claim that every row-level permission was finished in two days. Authorization was not a switch we could turn on once; it was an area that had to mature continuously with tables, read paths, and operational reviews. What did become clear then was that a successful browser subscription must never stand in for authorization verification.

Three layers of recovery for missed events

Once we changed the objective of real-time synchronization from “receive every event on the first try” to “return to the latest state even after missing one,” the design changed with it.

The first layer was an explicit initial fetch. When a person opened the screen, the client first read the current state from the server. Realtime was responsible only for announcing subsequent changes.

The second layer was a resynchronization immediately after subscribing. To close the gap between the initial fetch and the channel becoming ready, we reconciled the state once more as soon as the subscription was established. Instead of assuming “we already read it once,” we checked again at the moment we began listening.

The third layer was a version check that slowed down in stages. Rather than continually reading the entire game state, we checked only a small integer version. We checked more frequently during the short period just after connecting, when a mismatch was more likely, and increased the interval as the connection stabilized. Only when the version had advanced did we fetch the full state again.

With this structure, Realtime reflected changes immediately when it was healthy. If an event briefly went missing, the lightweight version check discovered the difference and reread the source of truth. We also resynchronized once when the network recovered or the page became visible again.

The important change was not eliminating fallback reads, but making them small. Instead of repeatedly fetching all game state and messages at short intervals, we asked one narrow question with a version number: “Am I behind?” Realtime handled the fast path, while the stored source of truth and recovery path were responsible for correctness.

This approach did not eliminate missed events either. We accepted that they could occur and bounded the detection time and recovery cost. The verification procedure in the operational notes from that period did not claim that “no event is ever missed.” It checked whether, after briefly disconnecting and reconnecting the network, clients converged on the same version within a defined period.

For an older iPhone, we left a way back instead of promising full compatibility

During the same period, the first screen on one older iPhone never moved past the loading state. The problem also appeared in Chrome, so at first we suspected the browser type. But Chrome on an iPhone runs on the same system WebKit. Desktop Chrome’s version alone could not tell us whether the device was compatible.

The operating system was below the framework’s official minimum support range at the time. That left two choices: say we supported it and retrofit every modern runtime behavior for the older environment, or state the support boundary honestly while making a best effort to keep people from being trapped on a black screen or in endless loading.

We chose the second.

  • We broadened the browser build target as far as practical.
  • In older environments, we reduced or stopped heavy animation such as pets and the space background.
  • Important navigation such as sign-in could fall back to a full-page load instead of relying only on a client-side transition.
  • Even if the initial client execution failed, a reload action and sign-in link appeared on a static screen after a defined wait.
  • We gave the initial fetch a timeout and showed a state from which the person could retry instead of an endless loading indicator.

The fourth item mattered especially. If the recovery button appears only after React starts normally, the person whose React runtime failed will never see that button. A minimal recovery path therefore had to remain outside the polished app screen.

These measures were not a promise that every feature would be officially supported on the older iPhone. Some features, including web push, required a newer operating-system version, and we could not reverse every limit of the framework runtime itself. What we could promise was not “everything will work,” but “when it does not, we will not leave you trapped without an explanation.”

Three assumptions we had to correct

Looking back on those two days, the ideas we needed to fix before the implementation stand out.

1. We took “free” to mean “costless”

Even a free plan has boundaries for network transfer, storage, connections, and request counts. If we calculate cost from a pricing table alone, we miss how many times the application moves the same data. A free tier is a condition that helps development begin, not permission to stop thinking about system structure.

2. We treated a connection indicator as the product’s truth

An open socket and a successful channel subscription describe the transport path. Whether the data a person sees is current is a separate question. We could explain synchronization only by comparing the version in the source of truth with the last version the client had confirmed.

3. We saw compatibility only as a one-line yes-or-no support statement

Even when an older device cannot be fully supported, there is still useful work to do. We can turn off heavy features, put an end to waiting, and provide a static recovery link. Between “not officially supported” and “do nothing” lies a wide field of design choices.

The checklist we kept for real-time features

If I were building a similar feature again, I would begin by writing down these questions.

Where is the source of truth?

Do not use an event stream or screen state as the source of truth. Choose storage that can be read again to reconstruct the same state even after every connection is lost.

How little information can each event contain?

Instead of retransmitting the full change, announce which resource’s version changed. Fetch sensitive source data through a read path that verifies authorization again.

How do we close the gap between the initial fetch and subscription readiness?

Resynchronize immediately after subscribing, or read missing entries from a cursor. The order must be explicit in documentation and tests.

Is authentication applied before subscribing?

Distinguish sign-in, group membership, room access, and game participation. Do not substitute hiding a channel name for access control.

Is the recovery check light enough?

Do not keep reading the entire state. Check a small value such as a version or last-modified time. Check briefly and often just after connecting, then less often once stable, balancing cost and recovery time.

What happens when the page becomes visible again or the network returns?

Use browser visibility changes and connection recovery as resynchronization signals. Do not merely redraw the subscription-status icon.

Is there an exit even if the client code never starts?

For browsers outside the support boundary, explain the difference between guaranteed functionality and best-effort recovery. Make the essential recovery link visible even if the app runtime fails.

Have we distinguished the limit at that time from the limit today?

Pricing plans and free allowances can change. Preserve the basis for the historical decision in a retrospective, and verify current official information again when making a present-day design decision.

The synchronization problem ultimately became an authorization problem

By the end of those two days, the path that delivered changes reacted more immediately than periodic polling, and there was a way to discover a missed event and reconcile state. We did not conclude that the problems had disappeared from every network and every older device. Instead, we could explain which state was authoritative, the order in which the system recovered when a connection wavered, and what it would show in an environment it could not fully support.

One question remained.

Who may read that source of truth, who may receive change signals, and who may actually change the state?

Before we built the real-time features, connection speed and cost had seemed like the largest problems. As the connection became more stable, the layers of authorization became clearer. Permission to operate the platform, membership in a group, and permission to approve a specific action were not the same thing.

In the next record, I will continue with why we separated those permissions before adding more features, and how we drew boundaries around group membership and human approval.

Continue reading

Previous story · Next story

Previous storyWhy One Photo Became a Cost Policy, and One Chat Room Became a Security PolicyNext story We Built Boundaries Before Buttons — Platform Authority, Group Membership, and Human Approval