This example demonstrates how to integrate the SpeedOf.Me speed test API into a Windows WPF application using WebView2.
The SpeedOf.Me API is JavaScript-based, so Windows apps use WebView2 to:
System.Text.Json and C# 8 nullable reference annotations, so it does not build as-is on .NET Framework 4.6.2)In Visual Studio: File > New > Project > WPF Application
Install-Package Microsoft.Web.WebView2
Copy speedtest.html and the code from SpeedTestWindow.xaml.cs to your project.
Create SpeedTestWindow.xaml with the XAML from the comments in the .cs file. The class is a partial class that calls InitializeComponent() and refers to SpeedTestWebView, StatusText, ResultsPanel, DownloadResult, UploadResult, LatencyResult and JitterResult. Those fields are generated from the XAML, so without this step the project does not compile.
SomApi.account = "YOUR_API_KEY";
SomApi.domainName = "your-domain.com";
Upload speedtest.html to the domain registered on your account and navigate to that URL:
SpeedTestWebView.CoreWebView2.Navigate("https://your-domain.com/speedtest.html");
It has to be a remote http(s) URL. The engine validates the page that embeds api.js against the domain registered on your account, and it reads that from the page's own URL. A page with no host can never match, so every test fails with error 1002 (Domain Mismatch). That rules out a local file next to the exe (file://) and NavigateToString (which loads as about:blank): neither is a usable loading method for this API, so there is no need to copy the HTML to your output directory.
WPF Application
└── SpeedTestWindow (WPF Window)
└── WebView2 Control
└── speedtest.html
└── api.js (defines the SomApi global)
▼
window.chrome.webview.postMessage(json)
▼
CoreWebView2.WebMessageReceived event
The HTML page posts the message object, not a JSON string:
window.chrome.webview.postMessage({
type: 'completed',
data: result
});
C# receives them:
SpeedTestWebView.CoreWebView2.WebMessageReceived += (sender, e) => {
string json = e.WebMessageAsJson;
// Parse and handle
};
Post the object itself. WebView2 then delivers real JSON and WebMessageAsJson deserializes it. If you wrap the payload in JSON.stringify() first, WebMessageAsJson returns a quoted JSON string literal instead of an object, JsonSerializer.Deserialize throws, and every message (ready, started, progress, completed, error) lands in your catch block. Use TryGetWebMessageAsString() if you would rather post a string.
System.Text.Json with PropertyNameCaseInsensitive maps download to Download, but it does not bridge an underscore. ip_address needs an explicit attribute or it never binds:
using System.Text.Json.Serialization;
[JsonPropertyName("ip_address")]
public string? IpAddress { get; set; }
speedtest.html normalizes the engine's payloads before posting them, so the C# side always deserializes numbers. This is what the raw callbacks give you inside the page.
onProgress(progress):
type: "download", "upload" or "latency"pass: pass number, '' during the latency phasepercentDone: 0 to 100currentSpeed: Mbps, only when SomApi.config.progress.verbose is true, '' otherwise and on every latency eventmaxSpeed: Mbps, same rule as currentSpeed, and '' while the running maximum is still 0latency / jitter: ms, only on the final latency event, '' otherwiseonTestCompleted(result) always carries every field. A disabled sub-test sends an empty string rather than omitting the key: download, upload, maxDownload, maxUpload, latency, jitter, testServer, ip_address, hostname, userAgent, testDate. upload and maxUpload are '' when uploadTestEnabled is false, latency and jitter are '' when latencyTestEnabled is false, testServer is '' when testServerEnabled is false, and ip_address is '0.0.0.0' with hostname '' when userInfoEnabled is false. Those empty strings are why the page normalizes before posting: a double property cannot deserialize "".
SomApi.config.sustainTime = 6; // 1-8 seconds
SomApi.config.testServerEnabled = true;
SomApi.config.userInfoEnabled = true;
SomApi.config.latencyTestEnabled = true;
SomApi.config.uploadTestEnabled = true;
SomApi.config.progress.enabled = true;
SomApi.config.progress.verbose = true; // required for progress.currentSpeed
1001 Invalid Account1002 Domain Mismatch2001 Test Error2002 Invalid server response2003 Request timeout2004 Test timeout2005 Speed test could not start (status N)2006 Speed test engine did not load1002 almost always means the page is not being served from your registered domain. 2006, and 2005 with status 0, mean the request never left the machine, usually an ad blocker, a content blocker or a frame-src Content Security Policy that does not allow https://speedof.me. A 2005 carrying any other status is a real HTTP response: the request reached the network and came back refused.
Check availability:
try {
string version = CoreWebView2Environment.GetAvailableBrowserVersionString();
// Runtime is available
} catch {
// Prompt user to install from:
// https://developer.microsoft.com/microsoft-edge/webview2/
}
using Microsoft.Web.WebView2.WinForms;
public partial class SpeedTestForm : Form {
private WebView2 webView;
public SpeedTestForm() {
InitializeComponent();
InitializeWebView();
}
private async void InitializeWebView() {
webView = new WebView2();
webView.Dock = DockStyle.Fill;
Controls.Add(webView);
await webView.EnsureCoreWebView2Async();
webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
webView.CoreWebView2.Navigate("https://your-domain.com/speedtest.html");
}
private void OnWebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e) {
// Handle message
}
}
MAUI's WebView exposes no host message bridge: it has no WebMessageReceived event and no window.chrome.webview.postMessage target, so the pattern above does not carry over. Reaching the underlying WebView2 takes a platform handler, which is out of scope for this example.
window.chrome.webview exists, and post the message object rather than JSON.stringify(...) when reading WebMessageAsJson