Yapster: a college project retro
A full-stack SvelteKit social media site built for college – what shipped, the Cloudflare Images to R2 migration, and the 512KB mystery I never solved.
The brief was a social media site. Profile pages, posts, follows, interactions. Full-stack, deployed, presentable. I built it in SvelteKit with Mongoose talking to MongoDB Atlas, auth through better-auth’s username plugin, and media on Cloudflare.
It shipped. Profiles, avatars, banners, posts with images, likes, saves, comments, follow/unfollow, a feed, an explore page, per-user tabs for posts/likes/saves. More than I expected to finish in the time available.
There are also about fifteen unchecked boxes in the README.
The stack
SvelteKit as both frontend and backend via its server routes. Mongoose for MongoDB because I know it and it’s fast to iterate with. better-auth for the auth layer – username/password with email, using the username plugin so users get @handles instead of having to remember their email at login. Docker for deployment; the compose file is two services and an external network.
One thing I added that’s probably overkill for a college project: a client-side post cache in a Svelte writable store, 5-minute TTL, cleared on any mutation.
export async function getCachedPost(id) {
let cache;
postCache.subscribe(value => { cache = value; })();
const cachedPost = cache.get(id);
if (cachedPost && (Date.now() - cachedPost.cachedAt) < 300000) {
return cachedPost.data;
}
cache.delete(id);
return null;
}The TTL is 5 minutes (300000 ms). On any post mutation – delete, edit – clearPostCache(id) fires. I wanted to learn the pattern and this seemed like a reasonable place to practise it. In practice the project has maybe two concurrent users, so it’s never been load-tested.
The Cloudflare Images detour
I started media on Cloudflare Images. The API is clean – you POST a file, get back a delivery URL, done. But Images has per-account upload limits that aren’t huge, and I hit them mid-build. Not the storage limit; the delivery/transformation quota.
The switch to R2 took an afternoon. The upload path changes from a purpose-built Images endpoint to the R2 objects API with a Bearer token, and the response goes from a Cloudflare CDN URL to whatever public domain you’ve wired to the bucket. Everything else stays the same – multipart/form-data to the SvelteKit image endpoint, UUID-keyed path under {userId}/{folder}/{uuid}.{ext}, delete-before-replace when updating.
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${R2_ACCOUNT_ID}/r2/buckets/${R2_BUCKET_NAME}/objects/${key}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${R2_API_KEY}`,
'cf-r2-jurisdiction': 'eu',
'Content-Type': file.type
},
body: file
}
);I’d probably reach for R2 first next time. Images is good for dynamic resizing and format negotiation; for static uploads where you control the dimensions client-side anyway, R2 is simpler.
The 512KB wall
Something in the stack was imposing a 512KB request size limit on uploads. I never tracked it down.
The client compresses before sending – browser-image-compression targeting 0.5MB, capped at 1920px on the longest edge. The server validates at 512KB too. Rate-limiting is handled by better-auth’s customRules on /api/image: 3 requests per 60 seconds.
What I couldn’t figure out was where the 512KB ceiling was coming from. The adapter originally had maxRequestBodySize: 10 * 1024 * 1024 set explicitly, and hooks.server.js had a manual content-length check at 10MB. I removed both – the problem stayed. At that point I’d already run out of days on the deadline, so I just standardised everything to 512KB and shipped it as-is.
Tracked it down after the fact: the limit is SvelteKit’s BODY_SIZE_LIMIT env var, which @sveltejs/adapter-node reads at runtime and defaults to 512KB. Set BODY_SIZE_LIMIT=10M (or whatever) on the running process – there’s no equivalent adapter-config option, which is why setting maxRequestBodySize did nothing.
The image editor for cropping and repositioning avatars/banners is still in the README TODO. So is blocking, and full post interaction features beyond likes and saves.
What I’d change
The better-auth additionalFields config puts following, followers, posts, likes, saves, and comments as string[] directly on the user object. That works fine at small scale – it’s just IDs. But it’d blow up if any of those arrays got long: every session hydration fetches the full user document, so a user with 10,000 posts would be serialising 10,000 IDs on every request. The right shape is a separate join table or at minimum a population-free reference. I knew this when I wrote it; the deadline said it didn’t matter yet.
The rate limit – 3 upload requests per 60 seconds – is very conservative. I set it early to avoid hammering Cloudflare while debugging and never bumped it up.
It’s archived because it’s stale, not because it’s done. I’d like to pick it back up one day and turn the TODO list into a social platform I’d actually use – the bones are there. Repo’s at github.com/joshdevous/yapster in the meantime.
Projects
what this writeup is about