Keeping a conversation alive while the user navigates away
Chat with file sharing in a recruiting platform. The upload was the easy half. The half that took thought was that switching dashboard views destroyed the thread and every attachment in it.
- react
- redux
- s3
- architecture
This was built at Uravo, as part of the engineering team on a recruiting platform. Recruiters and candidates needed to message each other and exchange documents without leaving the product.
The messaging itself was not the interesting part. The interesting part was that the feature kept losing people's conversations, and the reason turned out to be a decision made much earlier about where state lived.
The bug that defined the work
You would open a conversation, send a document, then click through to another part of the dashboard to check something. When you came back, the thread was empty. The message had been sent. It existed. You just could not see it any more, and neither could the attachment.
The component holding the conversation unmounted on navigation, and everything it knew went with it. From the user's side this is indistinguishable from data loss, and it is worse than data loss, because they had watched it work a moment earlier.
The fix was to stop treating the conversation as something a view owns. Conversation state moved out into React context backed by Redux, so it belongs to the session rather than to whichever screen happens to be mounted. Navigating away is now a rendering decision, not a lifecycle event that destroys data.
Why the files did not make that expensive
Preserving a thread across navigation gets costly if the thread holds attachments, because you are then holding file data in memory for as long as the session lasts.
That never became a problem, because of how uploads work.
Attachments go directly to S3 using a presigned URL. The browser asks the backend for permission to upload, gets back a short-lived URL, and sends the file to S3 itself. The bytes never pass through the application server.
The part that mattered for this problem is what happens next: once the upload completes, the browser keeps only the returned URL. Not the file, not a blob, not a preview held in memory. A string.
So the conversation state that survives navigation is text and references. It costs nothing to keep, which is what made preserving the full history cheap enough to be obviously correct rather than a tradeoff to argue about.
The two decisions look unrelated. Presigned uploads are usually justified by server load and memory pressure, and they do help with both. But the reason they mattered here was that they made a completely different problem cheap to solve.
What else that bought
Taking file bytes off the application server had the effects you would expect. Upload throughput stopped being bounded by the web tier. A large attachment no longer occupied a request worker for the duration of the transfer. Scaling the application and scaling file storage became separate questions.
None of that was the goal. It was the side effect of a decision made for a different reason, which is often how the good architectural choices go: they pay out somewhere you were not looking.
The rest of it
I also worked on the backend side of the same feature in Laravel: the upload authorisation endpoints, the routes behind the conversation, and the schema storing messages and their attachment references.
And separately, query optimisation. The platform held thousands of records, and several list views were doing the thing every growing application eventually does, which is issue one query to fetch a collection and then one more per row without anyone noticing until the collection got big. Fixing those is unglamorous and it is the difference between a dashboard that feels instant and one people complain about.
What I took from it
The lesson I actually kept is about where state is allowed to live.
The original code was not careless. Holding conversation state in the component that renders the conversation is the obvious thing to do, and it is correct right up until the moment that component is allowed to unmount while the conversation is still logically open.
The question worth asking early is not "where is this state used" but "what is the lifetime of this state, and does the thing holding it live at least that long?" When those two answers disagree, you get a bug that looks like data loss and is very hard to argue is not one.

