Measuring time to first frame and initial download size
How CrazyGames, YouTube Playables and Poki define first load, and how to measure the same bytes and milliseconds yourself with the Performance API and DevTools.
Each portal draws the “first load” line at a different SDK call, and at least one of them counts bytes after HTTP compression. If you measure your build to a different line, or on a server that compresses differently, your number will not match theirs. This guide lists what CrazyGames, YouTube Playables and Poki publish (as of 2026-10-01). It then shows how to record the same moment in your own code and add up the bytes the browser fetched before it.
What each portal counts
CrazyGames. The technical requirements measure initial download size from the start of loading to the first Gameplay start event from the SDK’s Game module. That event is meant for the moment the player can actually play, so menus and further loading steps fall outside it. The published caps are 50 MB for the initial download and 20 MB to be eligible for the mobile homepage. Without the SDK, the total file size counts against the same caps. For files loaded from external hosts, QA judges the time to reach gameplay instead, with a 20-second bar. The first-frame article adds that CrazyGames tracks both first-load time and size up to gameplayStart.
YouTube Playables. The stability requirements define initial bundle size as the data downloaded from the start of page load until the game calls gameReady. It MUST be under 30 MiB and SHOULD be under 15 MiB, and the game SHOULD allow interaction within 5 seconds. The certification FAQ says the count is taken after HTTP compression. The SDK reference requires firstFrameReady() before gameReady(), and gameReady() must not be called while a loading screen is still visible.
The generated “Page Summary” box at the top of the requirements page gives different figures from the numbered requirement. This guide uses the numbered requirement.
Poki. The requirements page asks for a small file size and progressive loading, with the reason that players tend to leave when loading takes more than 10 seconds. It sets no hard byte limit, but its engine guide says a good web game’s initial download should not exceed 5 MB, and 8 MB in total. The HTML5 SDK guide asks you to call gameLoadingFinished() when loading is done, so Poki can compute its conversion-to-play metric, and gameplayStart() on the player’s first input.
| Portal | Line is drawn at | Bytes counted | Published limit |
|---|---|---|---|
| CrazyGames | first gameplayStart |
from start of loading; compressed or not is not published | 50 MB, 20 MB for mobile homepage |
| YouTube Playables | gameReady |
from page load, after HTTP compression | MUST < 30 MiB, SHOULD < 15 MiB |
| Poki | gameLoadingFinished (loading metric) |
not published | no hard limit; guidance of 5 MB initial, 8 MB total, and the 10-second note |
CrazyGames writes “MB” and YouTube writes “MiB”. Neither difference matters much if you stay well under the limit, but when you are close, compare against the unit the portal uses.
Mark the same moment in your own code
The User Timing API gives you a named timestamp on the page’s performance timeline. Put performance.mark() in the same function that calls the portal’s event, so both always fire together:
import { markFirstGameplay } from './first-load-bytes.js';
function onPlayerCanPlay() {
sdk.game.gameplayStart(); // CrazyGames; ytgame.game.gameReady() on YouTube Playables
markFirstGameplay();
}
performance.measure() called with an empty options object and only an end mark measures from time zero, the time origin of your document, to that mark. It returns a PerformanceMeasure whose duration is your time to first gameplay.
Sum the bytes fetched before the mark
Every file the page fetched has a resource timing entry. The HTML document has a PerformanceNavigationTiming entry, which inherits the same size fields. The module below reads both kinds, keeps those that started before the mark, and adds up three sizes:
// file: examples/snippets/measuring-time-to-first-frame/first-load-bytes.js
// first-load-bytes.js: how long and how many bytes it took to reach the first playable moment.
// Call markFirstGameplay() at the same moment you call the portal's "playable" event
// (CrazyGames gameplayStart, YouTube Playables gameReady), then read the report in the console.
export const FIRST_GAMEPLAY_MARK = 'first-gameplay';
/**
* Pure: sums navigation and resource timing entries that started before `cutoff` (ms since time origin).
* Entries are plain objects with the PerformanceResourceTiming fields used below.
*/
export function summarizeFirstLoad(entries, cutoff) {
const summary = {
requests: 0,
transferBytes: 0, // headers + body as received (0 for cache hits and opaque cross-origin)
encodedBodyBytes: 0, // body before content decoding (gzip/br still applied)
decodedBodyBytes: 0, // body after content decoding
cached: [], // transferSize 0 but body size known: served from a local cache
unknownSize: [], // all sizes 0: cross-origin without Timing-Allow-Origin, or an empty body
finishedAfterCutoff: [], // started before the cutoff, still downloading when it was reached
};
for (const e of entries) {
if (e.startTime > cutoff) continue;
summary.requests += 1;
summary.transferBytes += e.transferSize ?? 0;
summary.encodedBodyBytes += e.encodedBodySize ?? 0;
summary.decodedBodyBytes += e.decodedBodySize ?? 0;
if (!e.transferSize && e.decodedBodySize > 0) summary.cached.push(e.name);
if (!e.transferSize && !e.encodedBodySize && !e.decodedBodySize) summary.unknownSize.push(e.name);
if (e.responseEnd > cutoff) summary.finishedAfterCutoff.push(e.name);
}
return summary;
}
export const toMiB = (bytes) => Math.round((bytes / 1048576) * 100) / 100;
/** Reads the timeline once the mark exists. `perf` is window.performance or a test double. */
export function measureFirstLoad(perf = globalThis.performance, markName = FIRST_GAMEPLAY_MARK) {
const mark = perf.getEntriesByName(markName, 'mark')[0];
if (!mark) throw new Error(`no "${markName}" mark yet: call markFirstGameplay() first`);
// An empty options object plus an end mark measures from time origin (navigation start) to the mark.
const measure = perf.measure('time-to-first-gameplay', {}, markName);
const entries = [...perf.getEntriesByType('navigation'), ...perf.getEntriesByType('resource')];
return { ms: Math.round(measure.duration), ...summarizeFirstLoad(entries, mark.startTime) };
}
export function markFirstGameplay(perf = globalThis.performance) {
if (perf.getEntriesByName(FIRST_GAMEPLAY_MARK, 'mark').length > 0) return null; // first one only
perf.mark(FIRST_GAMEPLAY_MARK);
const report = measureFirstLoad(perf);
console.info(
`[first-load] ${report.ms} ms, ${report.requests} requests, ` +
`${toMiB(report.transferBytes)} MiB transferred, ${toMiB(report.decodedBodyBytes)} MiB decoded`,
report,
);
return report;
}
summarizeFirstLoad() takes plain objects and a cutoff time, so it runs in a unit test without a browser. The other two functions only read performance.
The module makes two choices you should know about:
- A request that started before the mark counts in full, even if it finished later. Both portals talk about data needed to start the game, so a file you began downloading for the first scene belongs in the total. The
finishedAfterCutofflist shows which files were still in flight. If a large file shows up there, it is probably something you can defer. - Only the first call does anything. CrazyGames counts up to the first Gameplay start event, and later calls (after a pause, for example) would move the line.
Which size field matches the portal
MDN’s resource timing guide defines the three fields:
transferSize: response headers plus the payload body, as received. Per the property page, it is 0 when the file came from a local cache, and 0 for a cross-origin file without aTiming-Allow-Originheader.encodedBodySize: the body before content encodings such as gzip or Brotli are removed. MDN notes that if it differs fromdecodedBodySize, the response was compressed.decodedBodySize: the body after decoding, close to the size of the file on disk.
For YouTube Playables, which counts after compression, compare transferSize or encodedBodySize against the limit. Both only mean something if your local server compresses the way YouTube’s does. The FAQ says a local host that does not compress will report a larger initial bundle than the published game. It suggests either running the SDK Test Suite against an uploaded build or turning on gzip in your local server.
For CrazyGames, whether the count is taken before or after compression is not published on the requirements page or in the first-frame article. Treat decodedBodySize as the safe upper bound.
Three gaps can make the total too low:
- Cache hits. A cached file reports
transferSize0. The module lists these incached. Measure with the cache disabled. - Cross-origin files. Without a
Timing-Allow-Originresponse header, all three sizes read 0. These end up inunknownSize. Most portals want every file bundled anyway (Poki blocks external requests by default, per its requirements page). - A full buffer. The specification requires the resource timing buffer to start at 250 entries or more, according to MDN. A build with hundreds of separate sprites or sound files can overflow it. Call
performance.setResourceTimingBufferSize(1000)as early as possible in your first script.
The timeline belongs to your game’s document. MDN’s navigation timing page says only the current document is included. Inside a portal iframe, that means the measure starts when your iframe started loading, not when the portal page did.
Cross-check in the Network panel
The Chrome DevTools Network reference covers the settings that matter here:
- Check Disable cache to see what a first-time player downloads.
- Pick a Throttling preset (fast 4G, slow 4G, 3G) to get a feel for time on a slower link. The resulting numbers come from emulation, not from real devices.
- The status bar at the bottom shows the total transferred and the total resources size (uncompressed) for everything on the list.
- Turn on Big request rows in the panel settings. The Size column then shows the delivered size on top and the uncompressed size below.
- Filters such as
larger-than:500000orhas-response-header:content-encodingnarrow the list quickly.
The status bar adds up every request since the page loaded, including anything fetched after the player started. To compare it with the portal’s figure, load the page, wait for your [first-load] console line, and read the totals before you touch the game. Or rely on the module’s report, which stops at the mark.
Make the number smaller without moving the line
Calling gameplayStart or gameReady earlier does not help. CrazyGames asks for the event at the first playable state, and YouTube fails certification if gameReady fires while a loading screen is shown. What works is moving bytes behind the line:
- CrazyGames’ first-frame article suggests loading tutorial-critical assets first, starting gameplay as soon as the tutorial can be played, and loading the rest during the tutorial.
- The YouTube certification FAQ points the same way: load what is needed for interactivity, then lazy-load per level.
- For general size work (textures, audio, code splitting), see Keeping web game builds small. For how hosts compress files, see Serving compressed web builds.
What to check before you submit
performance.mark()sits next to the exact SDK call each portal measures (gameplayStart,gameReady,gameLoadingFinished).- The report was taken with the cache disabled, and
cachedandunknownSizeare empty. - For YouTube Playables, the local server compressed responses, or the number came from the SDK Test Suite run against an uploaded build.
- The total is compared in the portal’s unit (MB or MiB) against the current limits on /portals/.
- Nothing large appears in
finishedAfterCutoffthat the first scene does not need. - The resource timing buffer was raised if the build fetches more than 250 files before gameplay.
- The build passes the portal readiness checker. It checks total size and file count, not the compressed initial download.
Limits of this guide
No load times or sizes were measured for this guide. The code and its tests check the arithmetic, not any portal’s tooling. CrazyGames does not say whether its initial-download count is taken before or after compression, and Poki publishes initial-download guidance (5 MB) rather than a hard limit. The first is marked “not published” above. Whether resources fetched inside Web Workers appear in the window’s timeline was not checked. If your engine downloads files from a worker, confirm the total against the Network panel.
Questions
- Does YouTube Playables count compressed or uncompressed bytes for the initial bundle?
- Compressed. The certification FAQ says the initial bundle size is the bytes downloaded until gameReady, after HTTP compression, so a local server without compression shows a larger number than the published game.
- Can I fire gameplayStart earlier to make my CrazyGames initial download smaller?
- No. CrazyGames says the event should fire when the player reaches a playable state, which excludes menus and extra loading steps. Move assets behind that moment instead, for example by loading the tutorial first.
- Why does transferSize show 0 for some of my files?
- MDN lists two cases: the file came from a local cache, or it is cross-origin and the server did not send a Timing-Allow-Origin header. Disable the cache in DevTools and serve everything from your own origin before you measure.
Sources
- Technical requirements (CrazyGames developer docs)
- Getting to the first frame (CrazyGames developer docs)
- Stability and performance requirements (YouTube Playables)
- Certification FAQ (YouTube Playables)
- Playables SDK reference (YouTube Playables)
- Requirements (Poki for Developers)
- Choosing a web engine: size guidance (Poki for Developers)
- PokiSDK: HTML5 (Poki for Developers)
- Performance: mark() method (MDN)
- Performance: measure() method (MDN)
- Resource timing (MDN)
- PerformanceResourceTiming: transferSize property (MDN)
- PerformanceResourceTiming: encodedBodySize property (MDN)
- PerformanceNavigationTiming (MDN)
- Performance: setResourceTimingBufferSize() method (MDN)
- Timing-Allow-Origin header (MDN)
- Network features reference (Chrome DevTools)