Reporting Web 3D Capabilities on a Budget of $3 a Month<br>I run Web3DSurvey.com as a community service. It collects real-world WebGL, WebGL 2, and WebGPU capability data so 3D web developers can pick features from measured support rates instead of guessing. I have written about why I built it and what the v2 rewrite added. This post is about a different question people keep asking me: what does it cost to run, and how does the pipeline hold up?
In April 2026 the services that serve the site and store the data cost about C$3. During June 2026 the collector received roughly half a million requests. Each request carried one sample with up to 596 data points, for close to 300 million data points a month.
I started this project in early 2023 to learn React and BigQuery, neither of which I had used in production. The learning stuck, and so did the low bill. The architecture separates collection, reporting, and the costs of running both.
Collector#
What the collector measures#
The collector is a small script that a 3D site embeds through an iframe. After probing the browser for WebGL, WebGL 2, WebGPU, WebXR, and device capabilities, it submits the resulting stats object to the api server. The dedicated api-server drops bot traffic, adds normalized platform, browser, and engine fields, then inserts samples into BigQuery in batches of ten (which cuts down on the BigQuery bills).
I initially used the legacy streaming insert API but I replaced it with the BigQuery Storage Write API. The current volume fits inside its monthly free ingestion allowance, so this reduced significantly my BigQuery costs.
Half a million samples in BigQuery#
Web3DSurvey receives about 500,000 collector requests each month. Each request carries one sample with up to 596 data points. That works out to about 300 million possible data points a month. Some fields are empty when a browser does not support an API, but the shape is wide enough that I want a database built for analytical scans.
I have tried storing analytics data in PostgreSQL before. PostgreSQL is excellent for application transactions, but wide on-demand analysis over a growing event table becomes expensive and difficult to keep responsive. I would end up building summary tables, background aggregation jobs, or a second analytics system around it.
BigQuery gives me that analytical system directly. The samples go into five day-partitioned tables for browser, WebGL, WebGL 2, WebGPU, and WebXR data. Each partition expires automatically after 120 days:
bq mk --table \<br>--time_partitioning_field createdAt \<br>--time_partitioning_type DAY \<br>--time_partitioning_expiration 10368000 \<br>--schema ./webgl2.json \<br>web3dsurvey:stats_v4.webgl2
The retained tables currently occupy about 6.2 GiB in Google's us-central1 region in Council Bluffs, Iowa. BigQuery includes the first 10 GiB of logical storage each month, so storing this dataset currently costs C$0. Even without that allowance, the list price would be about US$0.14 a month.
The paid work is inserting data and pulling it back out for analysis. This project uses legacy streaming inserts, which charge for successfully inserted rows. On-demand queries charge by bytes scanned after a large monthly free allowance. The reports only query the last 7 days, so they scan a narrow slice of the retained data.
Reporting#
The collector and reporting paths run independently. The api-server writes samples, while the web3dsurvey service reads aggregates for the public site. A traffic spike on one side cannot starve the other.
Small queries and JavaScript rollups#
The reporting service does not run one giant aggregation query. It runs many small ones and assembles the summaries in JavaScript.
A single feature query pulls the raw true/false counts grouped by platform and browser, filtered to the 7-day window:
SELECT<br>platform.name AS platformName,<br>browser.name AS browserName,<br>COUNTIF(extensions.`EXT_color_buffer_float` = TRUE) AS `true`,<br>COUNTIF(extensions.`EXT_color_buffer_float` = FALSE) AS `false`,<br>FROM `web3dsurvey.stats_v4.webgl`<br>WHERE platform.name IS NOT NULL<br>AND version >= 7<br>AND DATE(createdAt) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)<br>GROUP BY platformName, browserName
Then JavaScript turns those counts into the support fractions, per-platform breakdowns, and per-browser-family rollups the page displays. The same pattern handles cumulative limit distributions, adapter and vendor hierarchies, and top-N trimming. Keeping the aggregation in JavaScript keeps the SQL simple and the logic in one language I can test.
The cost is fan-out. A full WebGL report runs one of these queries per extension and per parameter, plus adapter and support queries, all in parallel:
const [featuresArray, limitsArray, rendererInfo /* ... */] = await Promise.all([<br>Promise.all(extensionNames.map((name) => getFeatureStats(tableName, 'extensions', name))),<br>Promise.all(parameterNames.map((name) => getLimitStats(tableName, 'parameters',...