
Most of the hard problems in a social app aren't the feed algorithm or the database schema. They're the small, unglamorous parts: does the upload show progress, does a draft survive a refresh, does a broken username crash a profile link. This is a walkthrough of how that layer got built for Circlenet, including a few places where the first version wasn't actually good enough and had to be rebuilt.
If you're curious where this project started, I wrote about the reasoning in why I built Circle.
The frontend runs on Next.js, the backend on Express with JWT. Every protected request needs a Bearer token and a user ID header. The token started out in localStorage under circle_token, but some API responses nested it inside a circle_user object instead. That mismatch caused quiet failures where a valid session looked logged out.
The fix is a single helper that reads both locations once and returns a consistent shape:
function getAuthHeaders() {
const userString = localStorage.getItem('circle_user');
let token = localStorage.getItem('circle_token');
let userId = null;
if (userString) {
try {
const user = JSON.parse(userString);
token = token || user.token;
userId = user.id;
} catch (e) {
// malformed circle_user, fall back to whatever token we already have
}
}
return { token, userId };
}
One parse, one fallback path, no duplicate logic branching on whether the token existed first. Every upload and API call routes through this, which is what actually killed the intermittent 401s, not just having a token somewhere.
fetch doesn't expose upload progress, which is a problem the moment someone attaches a 50 MB video and the UI just sits there. Switching to XMLHttpRequest for uploads brings back upload.onprogress:
function uploadWithProgress(url, formData, onProgress, token, userId) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('X-User-Id', String(userId));
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => resolve(JSON.parse(xhr.responseText));
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(formData);
});
}
A percentage-based progress bar sits under the submit button, driven by the resulting uploadProgress state. Small change, but it's the difference between an app that feels broken on a slow connection and one that clearly isn't.
The original lightbox just showed an enlarged image. It needed to be a full post viewer instead: caption, author, likes, comments, repost, share, download, report, all in one modal.
Opening the lightbox now passes a meta object (postId, caption, author, avatar) from the originating PostCard, and the modal fetches the full post on open:
useEffect(() => {
if (postId) {
fetchPost(postId).then(setPostData);
}
}, [postId]);
Video posts get the same treatment. PostCard passes a type: 'video' item with a poster thumbnail, the lightbox renders a native <video> element with controls, and a double-click on the inline player opens it full screen with a small "Expand" hint. The result is that images and video share one interaction model instead of two inconsistent ones. This is separate from the live side of video on Circlenet. If you want the WebRTC mesh side of things, that's covered in how I added real-time streaming to Circlenet solo.
Auto-save only ever tracked one draft, which broke down the moment someone started a post and an article in the same session. The fix is a small drafts module backed by a circle_drafts collection in localStorage, where each entry has an id, a type (post or article), and its content:
export function saveDraft(draft) {
const drafts = getAllDrafts();
const existingIndex = drafts.findIndex(d => d.id === draft.id);
if (existingIndex >= 0) {
drafts[existingIndex] = { ...drafts[existingIndex], ...draft };
} else {
draft.id = draft.id || crypto.randomUUID();
drafts.push(draft);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts));
}
The compose page loads any saved draft via ?draftId=..., and a dedicated drafts page lists everything with preview and edit actions. Auto-save still runs for quick recovery, but two smaller fixes matter just as much as the drafts system itself:
circle_auto_save_enabled flag, and the save callback checks it before writing anything. Some people find silent background saving unsettling; now they can turn it off with one click.The comment section was a flat list, which doesn't scale once replies start stacking up. Comments are now a tree keyed by parentId, with an expandedReplies set controlling what's visible. Threads with replies expand by default; toggling is just a set operation:
const toggleReplies = (commentId) => {
setExpandedReplies(prev => {
const next = new Set(prev);
next.has(commentId) ? next.delete(commentId) : next.add(commentId);
return next;
});
};
Individual comments also got their own page at /comment/[id], backed by a new GET /api/comments/:id endpoint. That makes any comment shareable and indexable on its own, not just buried inside a post thread.
The inline photo editor modal worked for small images, but it had no full-screen mode and no video support, and passing large images between tabs through localStorage hit storage quota errors fast.
The fix was a standalone /editor route opened in a new tab, with image data passed through postMessage instead of storage:
// Compose tab opens the editor and waits for it to signal readiness
const editorWindow = window.open(`/editor?key=${key}`, '_blank');
window.addEventListener('message', (event) => {
if (event.data.type === 'editor_ready') {
editorWindow.postMessage({ type: 'image_data', key, data: preview });
}
});
The editor applies crop, rotation, and filters, then posts the result back and closes itself. No storage limits, no quota errors.
Video is the part worth being precise about. Full pixel-level editing across every frame of a video is expensive, so what actually ships is: CSS filters give a live preview on the <video> element, and on save, the editor captures a single frame to canvas, applies crop and color adjustments to that frame, and re-encodes it as a short WebM clip using captureStream() and MediaRecorder. It's a real, working feature, but it's frame-level editing dressed up as video editing, not true multi-frame processing. A production version of this would move to ffmpeg.wasm or a server-side encode. Worth knowing that going in if you're building something similar.
Two smaller fixes rounded things out:
PostCardSkeleton with a pulse animation renders while more posts load, triggered by an Intersection Observer at a 0.1 threshold, instead of a plain "Loading more..." line./profile/undefined when a username was missing. A fallback chain now checks username, then falls back to a userId-based query, then finally to a dead link only if both are missing:
const profileHref = user.username
? `/profile/${user.username}`
: user.id
? `/profile?userId=${user.id}`
: '#';
None of this was one big redesign. It was a series of specific fixes, each one closing a gap between what the feature looked like it did and what it actually did: auth that silently failed, uploads with no feedback, a lightbox that couldn't handle video, drafts that overwrote each other, an editor that hit storage limits, links that broke on missing data. The pattern worth taking away isn't any single snippet here, it's the habit of checking every feature against its edge cases before calling it done. That habit, shipping something real and fixing it in the open rather than waiting for it to be perfect, is the same argument I made in why shipping features matters more than perfection.