Haru Space Lab
Why One Photo Became a Cost Policy, and One Chat Room Became a Security Policy
How one chat photo exposed repeated-transfer costs, while one private room taught us that hiding a screen is not the same as enforcing server-side access.

Summary
At a glance
- We found that one uploaded photo was resent on every poll, then reduced transfers with delta queries, thumbnail-first display, and direct Blob uploads.
- Public Blob changed the cost path but did not guarantee private access, so storage and access still had to match the material's sensitivity.
- We enforced group and room access on the server and separated invite links from membership, then learned to validate invite code and password together.
This is a retrospective on our second week, drawing on Git history from July 6–12, 2026, the media and group-permission design documents from that time, and a private-room issue we discovered and fixed later.
When someone uploads a photo to a chat room, it looks to the human eye as though it was sent once.
But that was not what happened on Haru Space’s network at the time. The chat screen asked the server at regular intervals whether there were new messages, and the server returned a message list each time. If the original image was mixed into that list response as a string, even a photo the client had already seen came back with every query. As the number of people and photos in a room grew, one upload expanded into many downloads.
By the second week, I could no longer treat a photo as a simple attachment feature.
How we load a single photo is a UI implementation, a data-transfer policy, and ultimately a cost policy.
Chat rooms raised a similar set of questions that same week. Does hiding a room from a list make it private? Does receiving an invitation link immediately make someone a member of the room? Should a platform administrator naturally be able to read every group’s conversations?
Following those questions turned a chat room from a menu item into a permission boundary.
Why did one photo keep getting transferred?
The early chat system checked for new messages through short-interval polling. The implementation was simple, and it looked fast enough in a small test environment. The problem was that we had considered query frequency and response size separately.
Three things overlapped in that design.
- The screen repeatedly queried the room list and message list while it remained open.
- The message list could return a bundle of recent history again, rather than only new entries.
- When no thumbnail was available, the list could use the original image data instead.
Each choice seemed minor on its own. Together, they were different. The rough scale of repeated-transfer risk was the original file size multiplied by the number of queries and connected users. As photos accumulated, database responses grew heavier, and both the transfer volume emitted by serverless functions and mobile data usage increased.
I want to be explicit about the limits of the record here. The commits show that we changed polling intervals and response shapes, but we did not preserve enough instrumentation to compare production transfer volume before and after the change under identical conditions. I therefore cannot claim that we “reduced costs by a certain percentage.”
What we confirmed was not a measured reduction rate, but a path in the code that inevitably produced repeated transfers. We estimated cost risk from original file size, repetition interval, and user count, and left the actual savings to be verified later through observability metrics. Keeping design estimates separate from operational measurements was itself an operational habit we learned at this point.
First, we asked less often and fetched only what changed
Our first move was not to eliminate polling, but to reduce waste. We lengthened the room-list refresh interval and made message checks less aggressive. We stopped repeated queries when the browser tab was not visible.
Next, we changed the scope of the response.
When a user first enters a chat room, the client receives a small set of recent messages to establish context. After that, it sends both the creation time and identifier of the last message it received and requests only what came later. To prevent messages saved at the same moment from being missed or accumulated twice, the client merges them by identifier.
This small change contained an important shift in perspective.
The old question was, “Are there new messages?” The new one was, “Compared with what I already have, what has changed?” The former makes it easy to ask the server for the entire current state over and over. The latter creates a system that exchanges only the delta.
Incremental queries are not a cure-all, of course. If the client remembers its last position incorrectly, records can be missed. If it tries to order data with the same timestamp without identifiers, messages can fall through at the boundary. That is why we used both time and identifier rather than relying on time alone, and separated the roles of the initial query and subsequent queries.
The original and the list image were not the same resource
Reducing polling still leaves waste if a large original image remains in the list response. So we changed the media path itself.
When a file is selected, the browser creates a small thumbnail for the list and message bubble. The server verifies the login session and permission to access that room, then issues only the limited authority needed for the upload. The browser uploads the original and thumbnail directly to object storage, while the message data retains only metadata such as the file name, format, size, and storage location.
When someone reads the chat, they see the small thumbnail first. If no thumbnail exists, the client displays a placeholder rather than silently loading the original as a substitute. It fetches the original only when the user opens the photo at full size. We kept a separate compatibility path so that attachments stored under the previous method would not suddenly disappear.
I summarized the structure in three sentences.
- The server decides who has upload permission.
- A list receives only the size it needs for a list.
- The original is loaded only for an action that needs the original.
This reduces the burden of having serverless functions relay the original file every time, as well as the size of database responses. It does not make the cost disappear. Downloading an original from object storage still consumes that store’s transfer allowance. Changing the cost path is not the same as eliminating the cost.
Public Blob was not a private vault
This section looks back at the storage method used in July 2026. It is not guidance on the current attachment-security policy or on whether sensitive materials may be uploaded.
The storage method we chose at the time was Public Blob. It helped simplify the media-transfer path because the browser could receive files directly from storage. As the name suggests, however, anyone who knows the link can access the file.
Blocking room access in the app does not cause the app’s permission checks to block an already known public object URL. Protecting a message list through group and room permissions, and privately delivering the stored original itself, are two different problems.
For that reason, we chose not to describe the use of Public Blob as meaning “attachments became fully private.” The second week’s implementation focused on reducing automatic original-file transfers and the cost of routing them through the server. It did not guarantee the confidentiality of the link itself.
The choice must change when material is highly sensitive. The system may need to check permissions on every request to private storage or issue a signed URL that remains valid only briefly. Retention periods, deletion, link reuse, and caching policy must be defined as part of the same decision.
This distinction later developed into Haru Space’s principle of separating public and private data.
A private app screen, an API that denies access, and a private stored object are three separate boundaries.
Once groups existed, membership had to accompany every query
While we were fixing the cost path for media, Haru Space was changing from a service used by one gathering into a service shared by multiple groups. This was not a matter of adding a group-selection screen. Every important query and mutation had to verify which group a user, room, message, notice, or menu setting belonged to.
The first permission model at the time distinguished a role that managed the entire platform, a role that managed one group, and an ordinary member. Once approved, a user entered only the default public room of their own group. We prohibited the old flow that automatically added every new user to every room. A public room could appear in the list for the same group, but a private room appeared only to an administrator or an existing participant. Password protection was treated as a separate condition from list visibility.
The most important sentence was near the bottom of the document.
Hiding buttons and rooms in the client is not security.
The screen merely helps users avoid mistakes. For reading and writing messages, viewing original attachments, or changing room settings, the server had to verify the session, group membership, and room membership again. Even if someone manually supplied an identifier from another group, they had to receive no data. We also had to be careful with response behavior so that mere existence was not exposed unnecessarily.
This principle separated the responsibilities of the screen and the server. The screen clearly shows “what this user can do.” The server makes the final decision about “whether this request is actually allowed.” When those answers diverge, we first verify that the server still rejects the request rather than treating a screen adjustment as the end of the fix.
An invitation link was not membership
Private rooms needed invitations so people could use them together. Following a link was convenient, but the link itself could not become permission.
We treated an invitation link as navigation information that points toward a room. Someone who is not logged in first returns to authentication, then resumes the original invitation flow after signing in. Even a logged-in client does not become a member merely by reading the link. It submits the invitation information to the server, and the server creates room membership only after verifying an approved user in the same group and a valid room.
This separation can make the experience slightly more complicated. In return, it prevents session and group checks from being skipped simply because a link remained in browser history or was forwarded through a messenger.
At the time, we divided the flow into four concepts.
- Link: information that tells the system which room the user wants to enter
- Authentication: the procedure that verifies who made the request
- Group membership: the state that confirms the person is an approved member of that community
- Room membership: the server record that says the member may enter this private room
If all four are bundled into one “invitation succeeded” event, it becomes difficult to tell at which stage access was granted. Separating them lets us explain why a flow failed and record the exact moment permission came into existence.
The first boundary was not a finished boundary
Written this way, it may sound as though the security problem was settled in the second week. It was not.
The first version of the group boundary selected one group during account registration and kept platform administrators and group affiliation more closely coupled than they are now. Later, as one person began participating in multiple groups, we separated account creation from group participation requests. We also separated permission to inspect the platform from membership that actually grants access to a group’s conversations. Lifecycle rules for inactive status after leaving a group, rejoining, and preserving historical records came later as well.
In other words, the second week’s achievement was not a completed permission system. It was the beginning of applying the group boundary to every piece of data. Claiming that all of today’s policies existed from the beginning would erase what we learned while changing them.
There was a more direct failure too. A few weeks later, we found a flaw in a password-protected, invitation-only room: the conditions for exposing the invitation code and validating the password lived on different paths. The screen looked locked, but under one combination the invitation information could be visible to a user who had not joined. The server also failed to enforce the two requirements—invitation code and password—together in a single decision.
We narrowed the response so that only participants received invitation information and prevented pre-join users from opening the related menu. The more important change was reordering the server flow so that it created membership only after both conditions passed. Had we only closed the screen, the same request sent another way would have remained a problem.
That failure showed that the initial decision to “separate invitation links from membership” could be correct while the claim that its implementation was sufficient could still be wrong. Even sound principles leave gaps when combinations are not tested.
Cost and permission leaked in the same way
Looking back, the media-cost problem and the room-permission problem resembled each other.
The original image was not needed in the list, yet it followed the response for convenience. Invitation information was not needed by users who had not joined, yet it could follow merely because it was present on the room object. Both began at a loose boundary where we thought, “It is already in the data, so sending it along should be fine.”
After the second week, we began attaching the same questions to every field in an API response.
- Is it strictly necessary for this screen and this action right now?
- Does the requester have the affiliation and permission to receive this value?
- How do its size and cost grow when the request repeats?
- Is it acceptable for the response to remain in a browser, log, or shared link?
Minimal transfer and least privilege look like different principles, but in practice they were the same design habit: send only the necessary amount, to the necessary person, at the necessary moment. Cost optimization often improves security, while granular permissions can reduce unnecessary queries.
The practical checklist our second week left behind
If I were building a similar chat or collaboration service, I would review it by units of data movement rather than feature units.
1. Examine request count and response size together on repeating screens
Do not merely lengthen the polling interval or reduce file size. Estimate the risk by multiplying one user’s hourly call count, the average size of one response, and the number of simultaneous viewers. Record the estimate separately from actual transfer-volume metrics.
2. Separate list data from detail data
Keep only titles, metadata, and thumbnails in a list. Request originals and long-form content separately when a user opens them. Do not automatically substitute the original when a thumbnail is missing.
3. Do not confuse the storage visibility boundary with the product visibility boundary
If a private chat uses a public object URL, document that limitation. Separate sensitive material into private storage with server-side permission checks or a short-lived access method.
4. Recheck affiliation on every read and write
Whether a menu is hidden on screen is only a secondary check. On every request, the server verifies user status, active group, room membership, and any required role. Include negative-path tests that submit identifiers from another group.
5. Treat links, codes, passwords, and membership as separate states
Each value answers a different question. When a room requires two or more conditions, do not pass them independently. Verify all of them in one server-side decision before creating membership.
6. Build the combination matrix before the happy path
Create a matrix for the same group and another group, participant and non-participant, public and invitation-only rooms, and password present or absent. In every cell, verify list visibility, invitation information, message reading, and the result of membership creation.
The next question left by a photo and a room
At the end of the second week, Haru Space could display photos more lightly and had its first standard for rejecting out-of-group requests on the server. That did not mean all costs were measured, attachments had moved to fully private storage, or the permission model was complete.
The important change was that we had begun to see operational units behind feature names.
Behind a photo were one upload, many list queries, and different lifetimes for its thumbnail and original. Behind a chat room were screen visibility, group affiliation, room membership, and the server’s final denial. Once we separated those units, cost and security stopped being post-launch checks and became materials used to build the feature itself.
Send one photo only once, and open one room only to the people who are allowed in. The second week’s work was ultimately about keeping that simple promise across every path through the code.
In the next entry, I will continue with how we replaced this polling structure with a realtime connection while dealing with both free transfer limits and the constraints of older iPhones, and how we traced the problem of a connection that remained alive while only some events seemed to disappear.
Leaving a reaction may store a random identifier in this browser to prevent duplicates.

