Alpine.Js Polling ASP.NET Core APIs For Updates

Not every UI needs a WebSocket connection. A lot of internal dashboards, admin screens, and status pages just need to check a backend every few seconds and repaint whatever changed. If you reach for SignalR every time you need this, you are pulling in a persistent connection, a hub, and client reconnection logic for a problem that a simple HTTP poll can solve in a handful of lines.

Alpine.js is a good fit for this kind of work. It is a small JavaScript library, about 15 kilobytes, that lets you wire up behavior directly in your HTML using attributes, similar in spirit to how Vue or Knockout worked before the SPA-framework era took over. There is no build step, no bundler, and no virtual DOM to reason about. You add a script tag and start writing markup.

In this article, we will build a small ASP.NET Core minimal API that returns weather-style data, then wire up an Alpine.js frontend that polls that endpoint every three seconds and updates a table without a full page refresh. Along the way I will point out where polling makes sense in production and where it does not.

Why Alpine.js instead of a heavier framework

Alpine’s core idea is reactivity through plain JavaScript objects. You declare a piece of state with the x-data attribute, and any element that references that state updates automatically when the value changes. There is no dependency graph to configure and no separate template compilation step, the HTML is the template.

Here is the simplest possible example, a counter.

<div x-data="{ count: 0 }">
    <button x-on:click="count++">Increment</button>
    <span x-text="count"></span>
</div>

The x-data attribute creates a small reactive object scoped to that div. When the button is clicked, count is incremented, and because the span uses x-text to bind to the same count value, it updates immediately. There is no manual DOM query, no addEventListener boilerplate, and no re-render cycle to think about.

Inline objects work fine for a quick demo, but for anything with real logic you want to move that state into a named data component using Alpine.data. This keeps your HTML clean and gives you a place to put methods.

<div x-data="count">
    <button x-on:click="increment()">Increment</button>
    <span x-text="value"></span>
</div>
 
<script>
    document.addEventListener('alpine:init', () => {
 
        Alpine.data('count', () => ({
            increment() {
                this.value++;
            },
            value: 0
        }));
 
    });
</script>

This does the same thing as the inline example, but the increment logic now lives in a named component registered on the alpine:init event, which fires once Alpine has loaded and is ready to register components. The value field behaves like a normal JavaScript property, you read and write it directly without any special syntax. This pattern, a named Alpine.data component with methods and fields, is what we will use for the polling table.

Building the weather API in ASP.NET Core

For the backend, we need a minimal API endpoint that returns some data we can poll. A simple model with a location, a description, and a temperature is enough to demonstrate the pattern.

public class Weather
{
    public string Location { get; set; } = "";
    public string Description { get; set; } = "";
    public string Temperature { get; set; } = "";
 
    public static ReadOnlySpan<string> Descriptions =>
        new(["Sunny", "Cloudy", "Rainy", "Snowy"]);
 
    public static ReadOnlySpan<string> Locations =>
        new(["Mountain", "Valley", "Desert", "Forest"]);
}

Nothing unusual here, three string properties and two static ReadOnlySpan collections used to generate random-looking data. The ReadOnlySpan approach avoids allocating an array on every request, which is a small but nice touch if this endpoint is going to be hit frequently.

Next comes the endpoint itself, registered directly in Program.cs.

using MountainWeather.Models;
 
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
app.MapGet("/weather", () =>
{
    return Results.Json(
        Enumerable
            .Range(1, 10)
            .Select(i => new Weather
            {
                Location = $"{Random.Shared.GetItems(Weather.Locations, 1)[0]} #{i}",
                Description = $"{Random.Shared.GetItems(Weather.Descriptions, 1)[0]}",
                Temperature = $"{Random.Shared.Next(32, 100)}℉"
            })
            .ToList()
    );
});
 
app.UseDefaultFiles();
app.UseStaticFiles();
 
app.Run();

The /weather endpoint generates ten random weather records on every request using Random.Shared, which is thread-safe and the recommended way to get randomness in ASP.NET Core since .NET 6. UseDefaultFiles and UseStaticFiles are what let this app serve an index.html file straight out of wwwroot, which is where our Alpine.js frontend will live. In a real application this data would come from a database or a downstream weather service, but for demonstrating the polling pattern, random data is fine.

Wiring up the polling table with Alpine.js

With the API in place, the frontend is a single static HTML file. It loads Alpine.js from a CDN, defines a weather data component, and renders a table that refreshes itself on a timer.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Weather</title>
    <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
    <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body>
<main class="container-fluid">
    <table class="table table-striped" x-data="weather">
        <thead>
        <tr>
            <th>Location</th>
            <th>Description</th>
            <th>Temperature</th>
        </tr>
        </thead>
        <tbody>
        <tr x-show="locations.length === 0">
            <td colspan="3">
                0 Locations Found.
            </td>
        </tr>
        <template x-for="l in locations">
            <tr>
                <td x-text="l.location"></td>
                <td x-text="l.description"></td>
                <td x-text="l.temperature"></td>
            </tr>
        </template>
        </tbody>
        <tfoot>
        <tr>
            <td colspan="3" x-text="updated"></td>
        </tr>
        </tfoot>
    </table>
</main>
<script>
    async function getWeather() {
        let result = {};
        const response = await fetch('/weather');
        result.locations = await response.json();
        result.updated = new Date();
        return result;
    }
 
    document.addEventListener('alpine:init', () => {
        Alpine.data('weather', () => ({
            async init() {
                this.timer = setInterval(async () => {
                    const result = await getWeather();
                    this.locations = result.locations;
                    this.updated = result.updated;
                }, 3000);
 
                const result = await getWeather();
                this.locations = result.locations;
                this.updated = result.updated;
            },
            destroy: () => {
                clearInterval(this.timer);
            },
            locations: [],
            updated: "n/a",
            timer: null
        }));
    });
</script>
</body>
</html>

There is a fair amount happening here, so let us walk through it piece by piece. The weather Alpine.data component holds three fields: locations, an array that starts empty, updated, a display string, and timer, which stores the interval handle so it can be cleared later.

The init method is a lifecycle hook Alpine calls automatically when the component is created. Inside it, we call setInterval to fetch fresh data from the API every 3000 milliseconds, and we also make one immediate call outside the interval so the table is populated right away instead of showing an empty state for the first three seconds. The getWeather helper function does the actual fetch and wraps the response together with a timestamp.

The table markup uses the template tag with x-for to loop over the locations array. Alpine takes the contents of the template tag, clones it once per array item, and inserts the hydrated rows into the DOM in place of the template. This is the same pattern you would recognize from Vue’s v-for or Angular’s ngFor, just expressed through plain HTML attributes instead of a compiled template syntax. The x-show attribute on the placeholder row hides it automatically once locations.length is greater than zero.

Running this, you get a table that refreshes its contents every three seconds without a page reload, driven entirely by the fetch call and Alpine’s reactivity. Open the browser’s network tab while it runs and you will see a GET request to /weather firing on schedule.

Do not forget to clean up your interval

The destroy method in the component is easy to miss but matters in any application where this table might be removed from the DOM without a full page navigation, for example if it lives inside a tab panel or a modal that gets closed. Alpine calls destroy automatically when the element the component is attached to is removed, and clearing the interval there stops the fetch calls from continuing to fire in the background. Skipping this is a common source of quiet memory leaks and unnecessary network chatter in single-page style applications, even ones built with something as small as Alpine.

When polling is the right call, and when it is not

Polling like this is attractive because it needs nothing beyond a standard HTTP endpoint. There is no persistent connection to manage, no hub class, no client library beyond fetch, and it works through any proxy or load balancer without special configuration for sticky sessions or WebSocket upgrades. For internal tools, admin dashboards, or status pages where a few seconds of staleness is completely acceptable, this is often the simplest thing that works, and simplest is usually the right default.

It stops being the right call once you need true low-latency updates or you have a large number of connected clients. Every open tab polling every three seconds is a request your server has to handle whether or not anything changed, so at scale this pattern generates a lot of wasted work compared to a push-based model. If you need sub-second updates, or updates that must reach hundreds of clients the moment they happen, SignalR or Server-Sent Events are a better fit. SignalR gives you a persistent bidirectional channel with automatic reconnection and fallback transports, which is worth the extra complexity when the UI genuinely needs to feel live. Server-Sent Events sit in between, a one-way push channel over plain HTTP that is lighter than SignalR but still avoids the fixed-interval waste of polling.

A practical middle ground worth considering is adaptive polling, where the interval backs off when the tab loses focus or when recent responses show no changes, and tightens up again when the user is actively looking at the screen. That is more code than the plain setInterval shown here, but it keeps the simplicity of HTTP polling while cutting down on wasted requests.

Conclusion

Alpine.js gives you a genuinely lightweight way to build a self-updating UI without reaching for a full SPA framework or a real-time transport like SignalR. The entire example here, backend endpoint included, is well under a hundred lines of code, and every part of it is plain HTML, C#, and JavaScript that any developer on the team can read without learning a new toolchain. For dashboards and internal tools where a few seconds of latency is fine, this pattern is worth having in your toolbox before you default to something heavier.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading