Recently Played: bringing back my Last.fm component
Around 2010, my personal website had a Last.fm widget showing what I'd been listening to. It was a small thing - just a few album covers and track names in the sidebar - but it was very me. Back then your personal site was an extension of yourself, and having your music taste ticking away in the corner felt right.
Fast forward fifteen years and I've rebuilt this site from scratch several times over. The Last.fm widget never made the cut again. Not for any good reason - it just fell off the list each time. When I was recently working on the sidebar I remembered it, and thought why not bring it back?
There's something I like about the full circle. The web has gone through its various phases since 2010 - the single page app years, the JavaScript-for-everything years - and come out the other side valuing progressive enhancement and server-rendered HTML again. My site is built with Eleventy and deployed from GitHub Actions to S3 with CloudFront. It felt fitting to bring back a feature from an earlier era, built with the principles I care about now.
So here's how it works.
The architecture
The component is a two-layer system: build-time SSR for the initial HTML, and client-side polling for live updates. It's dropped into the sidebar as a <now-listening> WebC element alongside <my-details>.
Build time: listening.js → Last.fm API → tracks data → SSR into HTML
Runtime: Client JS → polls Last.fm API every 60s → diffs → updates DOM
├── localStorage cache (2min TTL) for instant loads
└── pauses when tab hidden, resumes on focus
Data layer: src/_data/listening.js
This is an Eleventy global data file that runs at build time. It calls the Last.fm API - specifically user.getrecenttracks - requesting the five most recent tracks for my account.
It has a three-second timeout so that if Last.fm is having a bad day, builds aren't left hanging. On any failure it returns { tracks: [] }, which means builds never break regardless of what the API does.
The raw API response gets mapped into clean objects - name, artist, album, url, art, and a nowPlaying boolean. All text fields are HTML-escaped and URLs are validated (only http: and https: schemes allowed) - this is the server-side XSS protection layer.
The data is then available to templates as listening.tracks via Eleventy's data cascade.
The component: now-listening.webc
The WebC component has three distinct parts.
Server-rendered HTML
A webc:type="render" script runs at build time, reading this.$data.listening.tracks. It generates the initial HTML so the page ships with real track data baked in. This means content is visible immediately on load - and for search engines or anyone browsing without JavaScript, this is the component. It's done. No spinner, no empty state.
A <template> element
An inert HTML template used by the client-side code for DOM cloning. It defines the track row structure with data-* attribute hooks - data-track, data-name, data-detail, data-art-placeholder, and data-now-playing. Nothing renders from this until JavaScript picks it up.
Client-side polling script
A self-executing IIFE (kept alive with webc:keep to prevent Eleventy from stripping it) that handles the live behaviour:
Polling - hits the Last.fm API every 60 seconds to keep the track list current.
localStorage cache - on page load, if a fresh cache exists (two-minute TTL), it renders from cache immediately and defers the first API call. This avoids the flash-of-stale-content problem on repeat visits or navigating between pages.
Diffing - compares JSON.stringify(tracks) against the last known state. If nothing has changed, the DOM stays untouched. No unnecessary reflows.
Visibility awareness - listens to visibilitychange to stop polling when the tab is hidden and restart when it becomes visible. If you leave the tab in the background for an hour, it's not hammering the API the whole time. Be a good citizen.
AbortController - cancels in-flight fetch requests when polling stops, so there's no risk of stale responses landing after the component has moved on.
Client-side XSS protection - uses the same safeUrl and esc helpers as the server layer. Content is inserted via textContent (inherently safe) and URLs are validated before being set as href or src attributes.
Design decisions
The main tradeoff worth calling out is the duplicated logic. The same API call and data mapping exists in both listening.js (server) and the client-side script. I could abstract it into a shared module, but the server code runs in Node during the Eleventy build and the client code runs in the browser. Keeping them self-contained means each layer is independently understandable and testable. The duplication is small and the mapping is straightforward - it's not the kind of logic that's likely to drift in dangerous ways.
The other thing I'm quietly pleased with is how little the component asks of the outside world. Between the visibility-aware polling, the localStorage cache, and the diff check before touching the DOM, it makes the minimum number of requests necessary. If you're pulling from someone else's API on every page load of your site, I think you owe it to them (and your users) to be thoughtful about it.
Was it worth it?
It took a couple of hours to build and it makes me smile every time I see it on the page. Sometimes the best features aren't the most technically ambitious - they're the ones that make a site feel like yours.
I might extract this into a standalone package at some point - the component is fairly self-contained and would slot into any Eleventy site with minimal config.
If you want to see it in action, it's in the sidebar at martinhicks.dev.