Skip to main content

Confirm

Are you sure?

C# · Code recipe

Run a Lighthouse audit in C#

Score any page for performance, accessibility, best practices and SEO, in .NET 8+ with HttpClient. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To run a Lighthouse audit in C#, POST a URL and a device to https://urlpipe.dev/lighthouse and read the JSON with JsonNode.Parse: four category scores from 0 to 1 and the lab metrics, LCP, CLS and TBT among them. An audit takes around 15 seconds, so the async variant with a webhook suits production. It costs 2 credits.

Free plan, no credit card. 1,000 credits a month.

One POST to /lighthouse runs a real Lighthouse audit in Chrome and answers with the four category scores — performance, accessibility, best practices, SEO — and the lab metrics behind the performance score. device picks mobile (the default, throttled CPU and network) or desktop.

In C# the report is read with JsonNode.Parse. Scores run from 0 to 1, so the program multiplies by 100 to print what the Lighthouse report shows; metrics carry a ready-formatted displayValue. The guide to reading a Lighthouse audit explains what each number means.

Setup

Before you start

Nothing to add from NuGet: HttpClient and System.Text.Json are part of .NET. Each program below is a whole Program.cs for a console project.

Terminal
dotnet new console -o LighthouseAudit && cd LighthouseAudit
export URLPIPE_API_KEY="your_api_key"
# replace Program.cs with a program below, then:
dotnet run

The request

Print the four scores, LCP, CLS and TBT

sync = true keeps the request open until the result is ready. PostAsJsonAsync serializes the anonymous object, and the property names go out as written, underscores included. HttpClient does not throw on a 4xx or 5xx, so check IsSuccessStatusCode. ?. carries a category or metric that came back null through to "n/a" instead of throwing.

Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;

// A sync call can take up to 60 s; HttpClient's default gives up at 100.
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLPIPE_API_KEY"));

var response = await client.PostAsJsonAsync("https://urlpipe.dev/lighthouse",
    new { url = "https://example.com", device = "mobile", sync = true });
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
    Console.Error.WriteLine($"URLpipe answered {(int)response.StatusCode}: {body}");
    return 1;
}

var report = JsonNode.Parse(body)!;
foreach (var name in new[] { "performance", "accessibility", "best-practices", "seo" })
{
    var score = report["categories"]?[name]?["score"]?.GetValue<double>();
    var shown = score is double s ? Math.Round(s * 100, MidpointRounding.AwayFromZero).ToString() : "n/a";
    Console.WriteLine($"{name}: {shown}");
}

var metrics = new[]
{
    ("LCP", "largest-contentful-paint"),
    ("CLS", "cumulative-layout-shift"),
    ("TBT", "total-blocking-time"),
};
foreach (var (label, key) in metrics)
{
    Console.WriteLine($"{label}: {report["metrics"]?[key]?["displayValue"]?.GetValue<string>() ?? "n/a"}");
}
return 0;

Run it: dotnet run

Given the example response on the docs page, it prints:

Output
performance: 95
accessibility: 88
best-practices: 92
seo: 90
LCP: 2.5 s
CLS: 0.05
TBT: 150 ms

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away. JsonNode reads one field without declaring a class for the response, and Task.Delay waits between polls without holding a thread.

Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;

const string Api = "https://urlpipe.dev";

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLPIPE_API_KEY"));

// No sync: the request is accepted at once and the work carries on without you.
var accepted = await client.PostAsJsonAsync($"{Api}/lighthouse", new
{
    url = "https://example.com",
    device = "mobile",
    report_to = "https://your-app.com/webhooks/urlpipe",
    labels = new { customer = "acme" },
});
if (!accepted.IsSuccessStatusCode)
{
    Console.Error.WriteLine($"URLpipe answered {(int)accepted.StatusCode}: {await accepted.Content.ReadAsStringAsync()}");
    return 1;
}
var token = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["token"]!.GetValue<string>();
Console.WriteLine($"Accepted {token}");

// The result is POSTed to report_to when it is ready. Polling by token is the
// other way to collect it: no endpoint needed, and a backup for the webhook.
var response = await client.GetAsync($"{Api}/result/{token}");
for (var attempt = 1; attempt < 60 && (int)response.StatusCode == 202; attempt++) // 202: still processing
{
    await Task.Delay(TimeSpan.FromSeconds(2));
    response = await client.GetAsync($"{Api}/result/{token}");
}

var body = await response.Content.ReadAsStringAsync();
switch ((int)response.StatusCode)
{
    case 200:
        break;
    case 202:
        Console.Error.WriteLine("Still processing after two minutes; try the token again later.");
        return 1;
    case 422:
        Console.Error.WriteLine($"The analysis failed: {JsonNode.Parse(body)?["error"]?.GetValue<string>()}");
        return 1;
    case 410:
        Console.Error.WriteLine("The result is past the 30-day window; send the request again.");
        return 1;
    default:
        Console.Error.WriteLine($"URLpipe answered {(int)response.StatusCode}: {body}");
        return 1;
}

var report = JsonNode.Parse(body)!;
foreach (var name in new[] { "performance", "accessibility", "best-practices", "seo" })
{
    var score = report["categories"]?[name]?["score"]?.GetValue<double>();
    var shown = score is double s ? Math.Round(s * 100, MidpointRounding.AwayFromZero).ToString() : "n/a";
    Console.WriteLine($"{name}: {shown}");
}

var metrics = new[]
{
    ("LCP", "largest-contentful-paint"),
    ("CLS", "cumulative-layout-shift"),
    ("TBT", "total-blocking-time"),
};
foreach (var (label, key) in metrics)
{
    Console.WriteLine($"{label}: {report["metrics"]?[key]?["displayValue"]?.GetValue<string>() ?? "n/a"}");
}
return 0;

Run it: dotnet run

Errors

Handle errors and retries

A local function keeps the retry rules next to the call, and the exception type is declared after the top-level statements, where C# requires it. RetryAfter.Delta is the parsed Retry-After header, and a body that is not JSON (a 401 answers in plain text) leaves every field null.

Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLPIPE_API_KEY"));

string body;
try
{
    body = await Urlpipe("/lighthouse", new { url = "https://example.com", device = "mobile", sync = true });
}
catch (URLpipeException e)
{
    Console.Error.WriteLine($"URLpipe: {e.Message}");
    return 1;
}

var report = JsonNode.Parse(body)!;
foreach (var name in new[] { "performance", "accessibility", "best-practices", "seo" })
{
    var score = report["categories"]?[name]?["score"]?.GetValue<double>();
    var shown = score is double s ? Math.Round(s * 100, MidpointRounding.AwayFromZero).ToString() : "n/a";
    Console.WriteLine($"{name}: {shown}");
}

var metrics = new[]
{
    ("LCP", "largest-contentful-paint"),
    ("CLS", "cumulative-layout-shift"),
    ("TBT", "total-blocking-time"),
};
foreach (var (label, key) in metrics)
{
    Console.WriteLine($"{label}: {report["metrics"]?[key]?["displayValue"]?.GetValue<string>() ?? "n/a"}");
}
return 0;

// POST a sync request and return the result body; retry the two 429s that clear by themselves.
async Task<string> Urlpipe(string path, object payload, int attempts = 5)
{
    for (var attempt = 0; attempt < attempts; attempt++)
    {
        using var response = await client.PostAsJsonAsync($"https://urlpipe.dev{path}", payload);
        var text = await response.Content.ReadAsStringAsync();
        var status = (int)response.StatusCode;
        if (status == 200) return text;
        if (status == 401) throw new URLpipeException("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");

        JsonNode? error = null;
        try { error = JsonNode.Parse(text); } catch (JsonException) { } // not JSON: no fields
        var code = error?["error"]?.GetValue<string>() ?? "";
        var message = error?["message"]?.GetValue<string>();
        var detail = message is null ? code : $"{code}: {message}";

        if (status == 429 && code == "rate_limited")
            // Sending too fast: Retry-After says how long the window has left.
            await Task.Delay(response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1));
        else if (status == 429 && code == "concurrency_limit")
            // Every parallel slot on your plan is busy with your own requests.
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
        else if (status == 504)
            // Still running on our side; the token collects it from GET /result/:token.
            throw new URLpipeException($"504 processing_timeout: collect it later with token {error?["token"]?.GetValue<string>()}");
        else
            // 403 email_unverified, 422 (a bad parameter, or a page that would not load),
            // 429 quota_exceeded: sending the same request again gets the same answer.
            throw new URLpipeException($"{status}: {detail}");
    }
    throw new URLpipeException($"429: still refused after {attempts} attempts");
}

class URLpipeException(string message) : Exception(message) { }

Run it: dotnet run

Details

What to know about /lighthouse

  • Lighthouse 13 reports four categories; the pwa key is always null, kept so the shape does not change.
  • The performance score weights TBT 30%, LCP 25%, CLS 25%, FCP 10% and Speed Index 10%.
  • INP needs real users and cannot be measured in a lab run; TBT is its lab stand-in.
  • "include_audits": "true" adds the full list of audits, with the elements to fix. Mobile and desktop are cached separately.
  • Want the audit run on a schedule, with a history and alerts? Full Stack Audit sells that as a monitored report; URLpipe is the primitive underneath.

Other languages

Run a Lighthouse audit in another language

More C#: every C# recipe

FAQ

Frequently asked questions

Mobile or desktop — which device should I audit?
Mobile, unless you know your traffic is desktop. It emulates a 360×640 phone with a 4× slower CPU and a slow 4G network, which gives the lower, more telling scores. Pass device: "desktop" for a 1350×940 window with no throttling.
Why isn't INP in the results?
INP is measured from real users interacting with the page, which a lab audit cannot do. Total Blocking Time is the lab metric that tracks it.
Why use the async variant for Lighthouse?
An audit takes around 15 seconds, longer on a heavy page. Async hands you a token at once and delivers the report to your webhook, so nothing holds a connection open waiting.
Do I need an SDK to call URLpipe from C#?
No NuGet package: HttpClient and System.Text.Json are part of .NET, and the API is one POST per job.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.