The usual way to put ads in Video.js is videojs-ima, which wraps Google's IMA SDK. It works, it is well maintained, and for most people it is the right answer.
It is also a third-party runtime you do not control, loaded from imasdk.googleapis.com, that brings its own VAST parser, its own wrapper-following policy, its own error handling and its own opinions about VPAID. There are reasons to want out: bundle and network budget, environments where the SDK is blocked or unavailable, a need to control wrapper depth and timeouts yourself, or simply not wanting a Google dependency in the playback path.
This is what the do-it-yourself path involves. I am not going to pretend it is less work, because it is more. But the work is bounded and the pieces are legible, which is not always true of the alternative.
contrib-ads is a state machine, not an ad player
The first thing to get straight, and the project says it plainly in its own README:
videojs-contrib-adsis not a stand-alone ad plugin. It is a library that is used by other ad plugins in order to fully support video.js.
And from the integration docs, on startLinearAdMode:
During this time, your ad plugin plays ads. videojs-contrib-ads does not handle actual ad playback.
So contrib-ads handles the hard, boring, easy-to-get-wrong parts of being an ad plugin in Video.js: pausing content, showing the spinner, restoring player state after the break, timing out if ads take too long, and redispatching media events so the rest of your app does not see ad playback as content playback. That last one alone justifies using it.
What it does not do is fetch VAST, parse VAST, choose a rendition, or fire a tracking pixel. That is your half.
The control flow
Initialise contrib-ads in the same tick as the player. This is a real constraint, not a style preference. The plugin relies on loadstart and will emit an error if it missed one.
import videojs from 'video.js';
import 'videojs-contrib-ads';
const player = videojs('content_video', {
controls: true,
sources: [{ src: '/content.mp4', type: 'video/mp4' }],
});
player.ads(); // same tick as videojs(), not in a ready callback
The handshake is: you tell contrib-ads when your ad logic is initialised, it tells you when a break is due.
// You fetch and parse, then announce readiness.
loadVast(AD_TAG_URL).then((ad) => {
currentAd = ad;
player.trigger('adsready');
});
// contrib-ads fires this once both `play` and `adsready` have happened.
player.on('readyforpreroll', () => {
if (!currentAd) return;
player.ads.startLinearAdMode();
player.src({ src: currentAd.mediaFile.url, type: currentAd.mediaFile.type });
player.one('adplaying', () => {
player.trigger('ads-ad-started'); // clears the loading spinner
fire(currentAd.impressions);
});
player.one('adended', () => {
player.ads.endLinearAdMode(); // content resumes
});
});
readyforpostroll gives you the postroll slot. Midrolls you drive yourself off timeupdate, calling startLinearAdMode and endLinearAdMode around each break.
That is the whole integration surface. Everything else is VAST.
Parsing the tag
VAST is XML, so DOMParser gets you most of the way. The parts you need for linear playback:
async function loadVast(url, depth = 0) {
if (depth > 3) throw new Error('wrapper limit');
const xml = new DOMParser().parseFromString(
await (await fetch(url)).text(),
'text/xml'
);
// A wrapper points at another tag. Follow it, and keep its trackers.
const next = xml.querySelector('Wrapper > VASTAdTagURI');
if (next) {
const inner = await loadVast(next.textContent.trim(), depth + 1);
return merge(collectTrackers(xml), inner);
}
const linear = xml.querySelector('InLine Creative Linear');
return {
impressions: [...xml.querySelectorAll('InLine > Impression')]
.map((n) => n.textContent.trim()),
errors: [...xml.querySelectorAll('Error')].map((n) => n.textContent.trim()),
duration: parseDuration(linear.querySelector('Duration').textContent),
mediaFile: pickMediaFile([...linear.querySelectorAll('MediaFile')]),
trackers: collectTrackers(xml),
};
}
Four things about that which are easy to get wrong.
Wrapper trackers accumulate, they do not get replaced. Every hop in the chain contributes its own <Impression>, <Error> and <TrackingEvents>. If you follow a wrapper and keep only the inline document's pixels, you have silently dropped the SSP's and the DSP's tracking, which is the kind of bug that surfaces as a discrepancy meeting three weeks later.
You need a depth limit. Wrapper chains can loop. The IMA SDK defaults to 4 redirects. Pick a number and enforce it.
<Duration> is HH:MM:SS or HH:MM:SS.mmm, not seconds.
const parseDuration = (s) =>
s.trim().split(':').reduce((acc, part) => acc * 60 + parseFloat(part), 0);
Everything is CDATA-wrapped and whitespace-padded. Always .trim(). A URL with a leading newline fails silently as an image request and you will not see it in the network tab without looking for it.
Picking a media file
<MediaFile> is repeated, and choosing badly is the most common cause of "the ad does not play on this device."
function pickMediaFile(files) {
const playable = files
.map((n) => ({
url: n.textContent.trim(),
type: n.getAttribute('type'),
bitrate: +(n.getAttribute('bitrate') || 0),
width: +(n.getAttribute('width') || 0),
delivery: n.getAttribute('delivery'),
}))
.filter((f) => document.createElement('video').canPlayType(f.type));
// Closest rendition at or below the player's width, then highest bitrate.
const target = player.currentWidth();
return playable
.sort((a, b) => b.bitrate - a.bitrate)
.find((f) => f.width <= target) ?? playable[0];
}
Filter on canPlayType before anything else. A tag will happily offer you video/x-flv and application/javascript alongside the MP4. That second one is a VPAID creative, which you do not want here and which is deprecated anyway.
Firing trackers at the right time
This is the part that separates a working integration from one that plays ads but reports nothing.
const QUARTILES = [
['start', 0],
['firstQuartile', 0.25],
['midpoint', 0.5],
['thirdQuartile', 0.75],
['complete', 1],
];
function attachTracking(player, ad) {
const fired = new Set();
const send = (event) => {
if (fired.has(event)) return;
fired.add(event);
(ad.trackers[event] || []).forEach(beacon);
};
player.on('adplaying', () => send('creativeView'));
player.on('timeupdate', () => {
const pct = player.currentTime() / ad.duration;
for (const [event, at] of QUARTILES) {
if (pct >= at) send(event);
}
});
}
const beacon = (url) => {
if (navigator.sendBeacon) navigator.sendBeacon(url);
else new Image().src = url;
};
The fired set is not optional. timeupdate fires several times a second, and a quartile pixel that fires forty times is worse than one that never fires, because the first looks like fraud and the second looks like a bug.
Two more:
Use the ad's declared <Duration>, not the media element's. They disagree more often than you would expect, and when they do, the declared duration is what the buyer paid against.
Do not derive quartiles from player.duration() during an ad. Depending on how the source switch went, that may still be the content duration, which puts every quartile in the wrong place with no error anywhere.
Reporting errors back
VAST has an error reporting mechanism that almost nobody implements on the do-it-yourself path, and it is the one that makes your tag debuggable for the ad server that sent it.
function reportError(ad, code) {
ad.errors.forEach((tpl) => beacon(tpl.replace('[ERRORCODE]', code)));
}
// no playable rendition after filtering
reportError(ad, 403);
// media file failed to load
player.one('aderror', () => reportError(ad, 405));
[ERRORCODE] is a macro the player substitutes. If you send the URL without substituting it, the ad server logs a literal [ERRORCODE] and learns nothing. The codes are a defined registry, and the wording is specific enough to be worth matching:
-
403 "Couldn't find MediaFile that is supported by this media player, based on the attributes of the MediaFile element." Your
canPlayTypefilter emptied the list. - 405 "Problem displaying MediaFile. Media player found a MediaFile with supported type but couldn't display it." You picked one and it failed anyway.
- 301 "Timeout of VAST URI provided in Wrapper element." Use this for wrapper hops that time out, not for wrapper documents that parse badly.
What you give up
Being straight about the tradeoff:
- VPAID. Which is fine. It is deprecated as of VAST 4.1, blocked in most CTV environments, and not worth building an isolation boundary for in 2026.
- VMAP ad rules. IMA can take a VMAP document and schedule the whole break structure for you. On this path you parse VMAP yourself or hardcode your break times.
- Measurement integrations. OMID viewability, in particular, expects a certified integration. If you have OM SDK obligations, the DIY path is not a shortcut.
- Someone else's compatibility matrix. IMA has been run against more devices than your code will be. That is a real asset and the main argument for staying with it.
Validate the tag before you debug the player
The failure mode I would flag hardest: when an ad does not play, the instinct is to instrument the player. Half the time the tag is malformed and no player would have played it. That is an especially easy trap here, because you wrote the parser, so you assume the parser is the suspect.
Check the tag first. I maintain vastlint, an open source VAST validator, largely because of this pattern. The tag tester takes a live ad tag URL, follows the wrapper chain, and shows you the resolved XML with the errors marked, which tells you in a few seconds whether you are debugging your code or someone else's tag.
If you would rather stay in your terminal, the CLI takes a URL directly and follows the wrapper chain for you:
$ cargo install vastlint
$ vastlint check "https://your-ad-server/vast?..."
The Video.js VAST guide covers the videojs-ima route in detail if you decide the DIY path is not worth it, including the plugin options that matter for wrapper depth and timeouts.
Worth it?
If you need VPAID, full VMAP scheduling, or certified viewability, use the IMA SDK. If you need a small, inspectable ad path with no third-party runtime, the code above is close to the whole shape of it. Roughly 200 lines for prerolls with tracking, and every one of them is yours to step through, which on the day something goes wrong is the entire point.
Top comments (0)