The New Last.fm Now Playing Overlay
How I rebuilt a Next.js music overlay into a Bun + Hono + SvelteKit monorepo, moved Last.fm calls into the browser to scale past 200 viewers, and swapped a fixed grid editor for free positioning.
Try it live (opens in a new tab)- View more posts using Svelte 5
- View more posts using SvelteKit
- View more posts using Bun
- View more posts using Hono
- View more posts using TypeScript
- View more posts using Tailwind CSS
- View more posts using Redis
- View more posts using PostgreSQL
- View more posts using Drizzle
- View more posts using Railway
Same widget, all new insides
I’ve written about this widget before. It still does one job: show whatever you’re playing on Last.fm as an OBS browser source, with the whole design baked into a shareable URL. What changed is everything you cannot see.
The first version was a single Next.js app on Vercel. What’s running today is a Bun + Hono + SvelteKit monorepo on Railway, and instead of routing every request through my server it calls Last.fm from each viewer’s own browser.
Live one is at fast.jamlog.lol.
I rebuilt it because the old version had a scaling problem I could not tune my way out of. This post covers what got better and what costs more to run now. If you want the story of the original build, that one’s written up separately.
What the first version got right
The Next.js build wasn’t a mistake. It shipped, people used it, it proved the idea worked.
The idea was to put the whole widget config in the URL hash, decode it on a /w page, and render it. No accounts, no database row per user, the link is the save file. I kept that part untouched. It is still the best decision in the project.
It handled the boring parts well too. Adaptive polling so the overlay didn’t hammer Last.fm, local progress estimation so the bar didn’t flicker when the API lagged, a session-key path for private profiles. None of that got thrown away. It got rewritten and cleaned up.
So the first version did its job. It found the product, and then the product ran into problems the old setup could not fix.
Where it started hurting
Once streamers with actual audiences started using it.
One IP against a rate limit
This is the one that forced the rewrite. Every viewer’s widget fetched Last.fm through my server, so when a streamer went live, dozens of browsers were all asking one server for their now-playing data, and that server hit Last.fm from a single IP.
Last.fm caps you at roughly 5 requests per second per IP. A handful of viewers is fine. Fifty tabs sharing one budget means the overlay starts getting throttled at the moment people are watching.
No setting fixed that. The architecture funneled all the traffic into the one place that could not scale.
The grid editor
The original editor placed elements on a fixed grid. Album art here, title there, slots you filled in. It worked, and every layout came out looking like a variation of the same template. Want the artist name floating in the bottom corner with a custom offset? The grid had no answer for that.
People wanted to design their overlay and the grid could not do it.
A framework doing too much
Next.js is a lot of machinery for what this app is. A static editor page, a static widget page, both running in the browser, plus a thin API. I was paying for SSR I never used and a build pipeline heavier than the job needed.
What changed
The new build is a different runtime model and a different repo layout, not Next.js with tidier files.
The monorepo
Two apps under one Bun workspace.
apps/web is a SvelteKit single-page app built with adapter-static, so it’s pure client-side rendering with no SSR. It owns the drag-and-drop editor, encoding and decoding the config to and from the URL, polling Last.fm, and rendering the widget. Svelte 5 with runes, Tailwind v4.
apps/server is a Bun-powered Hono service. In production it serves both the static build and the API on a single port. Redis sits in front of the few signed and proxied Last.fm paths, and Postgres (through Drizzle) backs optional analytics and contact emails.
Two apps is more than one, sure. Each piece has one job now instead of one framework doing all of them at once.
Calling Last.fm from the browser
Biggest change in the rewrite, so it gets its own section.
Last.fm and its album-art CDN both send Access-Control-Allow-Origin: *, which means each viewer’s browser can call ws.audioscrobbler.com directly. Public lookups (recent tracks, track info, album art, color extraction) now fire straight from the viewer’s machine, on the viewer’s own IP.
So every viewer spends their own per-IP budget. A streamer with a hundred viewers is a hundred separate IPs hitting Last.fm instead of one server choking on all of it.
The old line climbs with every viewer. The new one stays flat, because the load spreads across as many IPs as there are people watching. Hit the Δ Compare toggle on the chart to see the gap fill in.
The server is still there as the fallback. If a direct call fails at the transport level, a network blip or a CORS hiccup, the client retries through /api/lastfm/*.
Private profiles used to be the exception, since a hidden listening profile needs a signed request and the signature needs the Last.fm shared secret, which never leaves the server. What killed the exception is that Last.fm signatures carry no timestamp or nonce, so a signed URL stays valid as long as the session key does. The server signs the recent-tracks URL exactly once, hands it to the browser, and the browser polls Last.fm directly with it, same as a public profile. One signing request, then every poll after that is on the viewer’s own IP.
There is a BYOK option too. Drop in your own Last.fm API key and your widget uses it for the direct calls, so a throttle or revocation on the shared key never reaches you. The key rides along in the config like everything else so it survives the trip into OBS.
Polling
Last.fm pushes nothing. There is no websocket telling you a song changed, so the widget keeps asking, and the only question is how often.
Now that every widget polls from its viewer’s own IP there’s no shared budget to protect, and Last.fm’s ~5 req/sec per-IP allowance makes once a second comfortable. So that’s what it does:
Playing or not, it polls every second, so track changes, pauses, skips, and playback starting all show up within about a second. The only backoff left is a hidden tab, like an editor sitting open in the background, which drops to 5 seconds and stops wasting requests.
OBS browser sources report as visible, so overlays never hit that backoff and hold the one-second pace.
Between polls the progress bar doesn’t freeze waiting on the next fetch. It ticks locally off the track’s reported duration, driven by requestAnimationFrame, so it animates smoothly. The whole thing is a Svelte 5 runes class, $state for the live fields and $derived for progress and percent, so the UI reacts on its own.
The annoying edge cases
This is where most of the work went. Last.fm tells you a track is “now playing” but never tells you where in the track you are, and that gap creates three annoying problems.
Pause detection. A lot of scrobblers keep a song flagged “now playing” right through a pause. The only signal you get is your locally-estimated progress running past the track’s own length. Once it overruns the duration plus an eight-second grace period (enough to ride out the gap between songs without falsely flashing “paused”), the widget marks it paused.
Resume estimation. Start OBS halfway through a song and a naive widget shows the progress bar at zero. The new code checks recent scrobbles to estimate where playback is, so the bar lands close to the right spot instead of snapping to the start.
Loops and replays. Put a song on repeat and a naive widget sits there thinking it has been paused for ten minutes. The code watches scrobble timestamps, and if the same track scrobbles again more than a full duration after it started, it looped, so the widget re-anchors instead of freezing.
The editor: grid out, free layout in
The new editor throws out the fixed grid. Every element (background, art, title, artist, album, progress bar, duration, pause badge) has free x/y/w/h, a z-index, and optional snap relationships to other elements. Drag anything anywhere, and when you snap an element’s edge to another’s the relationship sticks, with the gap captured at drop time.
You also get per-element fonts, colors, and shadows, plus a switch animation for track changes. It is a layout tool now instead of a fill-in-the-blanks form.
The part I am happiest about is that this shipped without breaking one existing design. A version flag rides along in the encoded config: missing or 1 means the old grid, 2 means free layout. That flag picks the renderer, either WidgetLegacy.svelte or WidgetV2.svelte.
export function isV2(c: WidgetConfig | null | undefined): c is WidgetConfig & { v2: WidgetV2 } { return !!c && c.version === 2 && !!c.v2;}When an old grid design loads, a migrateToV2 step reads the legacy art position, text stack, and shadow settings and rebuilds the same look as a free layout, so everything is movable from there. It also carries the legacy fields through untouched, so if the version flag ever got lost the design falls back to the grid instead of breaking.
The URL is still the document
This part survived the rewrite on purpose. When you’re happy with a design the whole config serializes to JSON, gets base64url-encoded, and goes into the widget URL’s hash: /w#<blob>. The widget page reads it back, polls Last.fm, renders.
export function encodeConfig(c: WidgetConfig): string { const json = JSON.stringify(c); return btoa(unescape(encodeURIComponent(json))) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");}The URL is the save file. Copy it, paste it into OBS, done. No account to make, nothing stored on my server, nothing to leak. The editor keeps a localStorage autosave as a safety net but the source of truth is the link in your clipboard.
Locking down the server
It is small, and it is the part facing the open internet.
The image proxy, which is the fallback path for album art, only allows known CDN hosts. An open image proxy is an SSRF hole, and the allowlist closes it. There is also a loose per-IP rate limit, 60 requests per 10 seconds, that normal polling never touches. It only trips on someone spamming the endpoint.
My favorite property of the new server is that it fails open. Redis and Postgres are both optional, and if either falls over the widget keeps serving. You lose caching or visitor logging. The overlay stays up.
| Everything up | Yes | Yes | Yes | Yes |
| Redis down | Yes | No | Off (fails open) | Yes |
| Postgres down | Yes | Yes | Yes | No |
| Both down | Yes | No | Off | No |
Caching is short on purpose: recent-tracks responses live for one second, track-info for a day. The whole stack ships as one Railway service plus the Redis and Postgres plugins, and Drizzle migrations apply on the server’s first write, so there’s no manual migrate step on deploy.
Why the new one is better
The scaling problem is gone, and no part of that came from picking a trendier framework. Moving Last.fm calls into each viewer’s browser turned one shared bottleneck into a hundred independent budgets, which is the difference between an overlay that dies under an audience and one that holds at 200 plus concurrent viewers.
The editor is a real design tool now, and thanks to the migration nobody’s old URL broke to get there. The runtime is lighter, one Bun process serving a static SPA and a thin API, with no SSR tax on pages that were always client-only. And since the server fails open, a Redis or Postgres outage kills one feature instead of the whole widget.
It wasn’t free though
It was not all upside.
There are more moving parts now. The old build was one Next app. The new one is a monorepo with two apps, Redis, Postgres, and the wiring between them. That is real weight to run, even with everything failing open.
Browser-direct calls also mean the public API key ships in the client bundle. It is a public key, and BYOK exists for anyone who wants their own, but it is sitting out there in plain sight. That is the trade for killing the single-IP bottleneck.
The no-server-save model cuts both ways. Lose the URL, lose the design. The localStorage autosave catches most cases, and the link is still the only real backup.
Old vs new
| Area | Original | Refactor |
|---|---|---|
| App shape | One Next.js app | Bun monorepo: apps/web + apps/server |
| Frontend | React / Next.js | SvelteKit SPA (adapter-static, Svelte 5 runes) |
| Backend | Next API routes | Bun + Hono service |
| Hosting | Vercel | Railway (one service + Redis + Postgres) |
| Last.fm calls | All through the server (one IP) | Browser-direct, even for private profiles; server as fallback |
| Scale ceiling | Throttled past a handful of viewers | 200+ concurrent, each on its own IP |
| Editor | Fixed grid | Free positioning with snapping (x/y/w/h, z, snaps) |
| Old designs | n/a | migrateToV2 keeps every old URL working |
| When something breaks | Server in the hot path | Fail-open Redis + Postgres |
The first version was the right way to find out if this was worth building. This one is the right way to run it.
Try it at fast.jamlog.lol, source is here: