Thanks for the additional info! That was another approach I tried but in my case it didn’t work, or I may just have been doing things slightly differently.
After a ton of trial and error I think I have a reliable approach. Or at least it’s working so far. 
My take on the core issue based on the behavior I was seeing is that Wix seems to pre-create all custom element instances in a repeater before the data is available, and routes setAttribute calls through a worker proxy. By the time Velo’s onItemReady runs and tries to pass data to the elements, most instances aren’t connected (i.e., connectedCallback hasn’t been called), so the attributes land on disconnected proxy instances and never reach the rendered DOM elements. In my situation, no amount of setTimeout tricks in onItemReady reliably fixed this.
One thing I’ll mention before getting into the guts of what’s working for me, is in the course of troubleshooting I wound up using a single JSON attribute instead of multiple attributes on the custom audio player. I don’t know that this has any real impact on the working solution, but I decided to leave it alone for now.
So instead of setting src, track-name, artist-name, and cover-image separately, I packed everything into one JSON attribute:
customPlayer.setAttribute("player-data", JSON.stringify({
src: "https://...",
track: "Track Title",
artist: "Artist",
cover: "https://..."
}));
And then in the custom element code (which in my case is a JavaScript file that lives on GitHub), parse it in the attributeChangedCallback:
static get observedAttributes() { return ['player-data']; }
attributeChangedCallback(name, _old, val) {
if (name === 'player-data' && val) {
try {
const data = JSON.parse(val);
this._applyData(data);
} catch(err) {}
}
}
Again, not sure if that really matters or not but in case it’s helping this work, I figured I’d mention it.
Now as for what I think actually fixed it, here’s the approach I took.
Deferred hydration via forEachItem with a setTimeout
The idea here is don’t set attributes inside onItemReady at all; instead, after setting the repeater data, call a separate hydration function that uses forEachItem with a setTimeout to let Wix finish connecting all the elements first.
function hydrateCustomPlayers() {
const repeater = $w("#postsRepeater");
repeater.forEachItem(($item, itemData, index) => {
const rawUrl = itemData.audioFileUrl;
if (!rawUrl) return;
// Capture values in closure NOW to prevent last-row-wins bleeding
const capturedSrc = rawUrl;
const capturedTrack = itemData.title;
const capturedArtist = itemData._resolvedArtist;
const capturedCover = itemData._resolvedCover;
// Use a fixed element selector — Velo TypeScript requires literal strings
const customPlayer = $item("#kptzAudioPlayer");
setTimeout(() => {
if (customPlayer && typeof customPlayer.setAttribute === 'function') {
customPlayer.setAttribute("player-data", JSON.stringify({
src: capturedSrc,
track: capturedTrack,
artist: capturedArtist,
cover: capturedCover
}));
}
}, 1500 + (index * 50));
});
}
And call it immediately after setting the repeater data.
// After querying the CMS data ...
const results = await wixData.query("Posts")
.descending("publishDateTime")
.find();
// Pre-fetch all related data and attach it to each item
const enrichedItems = await enrichPostsWithShowData(results.items);
// Set the repeater data, then immediately kick off hydration
$w("#postsRepeater").data = enrichedItems;
hydrateCustomPlayers();
We’ll see where that enrichPostsWithShowData function comes from next. 
Pre-Fetch All Related Data Before Setting Repeater Data
This is critical if your CMS items have related data (like in my case I have Posts collection and a related Shows collection) that needs to be resolved before hydration. If you fetch that data inside onItemReady asynchronously, it may not be read when hydrateCustomPlayers runs.
So instead, I batch-fetch all related records up-front and attach them directly to each item.
async function enrichPostsWithShowData(items) {
// Collect all unique related IDs
const showIds = [...new Set(
items
.filter(item => item.show && typeof item.show === 'string')
.map(item => item.show)
)];
// One batch query instead of N individual fetches
let showMap = {};
if (showIds.length > 0) {
const showResults = await wixData.query("Shows")
.hasSome("_id", showIds)
.find();
showResults.items.forEach(show => {
showMap[show._id] = show;
});
}
// Attach resolved data directly to each item
return items.map(item => {
const showObj = showMap[item.show] || null;
return {
...item,
_showObj: showObj,
_resolvedCover: wixImageToUrl(showObj?.logo || DEFAULT_LOGO),
_resolvedArtist: item.airDate ? formatAirDate(item.airDate) : DEFAULT_ARTIST_TEXT
};
});
}
(For context, in my custom player I use the artist attribute to show the air date of a show.)
And then in a load() function, I do this:
const enriched = await enrichPostsWithShowData(results.items);
$w("#postsRepeater").data = enriched;
hydrateCustomPlayers();
My take on why this forEachItem + setTimeout approach works is because by the time the timeout fires, Wix has finished connecting all the custom element instances to the DOM. At that point setAttribute lands on the actual connected elements and triggers attributeChangedCallback correctly. The closure captures each item’s data immediately in forEachItem preventing any missing attributes or (and this is something I was seeing in some iterations of my attempts) variable bleeding between items.
The pre-fetch approach eliminates the race condition where async data fetches inside onItemReady might not be complete before the hydration timeout fires.
Currently I’m using a 1500ms base timeout with 50ms stagger per item which, like the collected attributes bit I mentioned above, is a holdover from previous experimentation, so I may be able to reduce this.
Anyway, long way of saying IT FINALLY WORKS! I really appreciate the input on this, and hope this all makes sense if someone else runs into a similar issue.
Thanks!