Skip to content

    Back to articles

    6 min read

    QuickGrid virtualization is a data contract

    Virtualization moves work; it does not remove it

    The .NET 11 Release Candidate 2 announcement says QuickGrid virtualization APIs are no longer experimental (.NET 11 Release Candidate 2). That makes this a good moment to look past the component switch and ask what the data source must promise. Virtualization can keep a browser from rendering every row, but it cannot make an ambiguous or expensive database query reliable.

    QuickGrid’s ItemsProvider makes that contract visible. It receives a request with a start index, a maximum row count, sort information, and a cancellation token. The provider returns matching rows and a total item count, which QuickGrid uses for paging and virtualization (QuickGrid component documentation). Those are not merely implementation details. They define how the grid asks for data and what the server must mean by “the next range.”

    I would treat the provider as an API boundary, even when it lives inside the same application as the component. Make its guarantees explicit: which filters apply, how a sort is interpreted, how ties are resolved, what a range means when records change, and how the total is calculated. If those rules are left implicit, a grid can look responsive while showing inconsistent rows or issuing costly queries.

    A range needs a stable order

    A start index only has meaning against a well-defined sequence. Suppose a grid sorts by creation time, and several records share the same timestamp. If the query orders only by that timestamp, the database has no unique rule for those tied records. One request can end at a different tied row than the next request starts from. The result can be a duplicate or a gap as the user scrolls.

    The fix is not specific to QuickGrid: make the ordering fully unique. The EF Core pagination guidance gives the same advice, using a unique property such as an ID as a final ordering key (EF Core pagination guidance). In practice, I would translate a requested sort into an allowed database ordering, then append a unique tie-breaker. The tie-breaker should be part of the contract, not an accidental property of the current query plan.

    For example, a provider with a fixed default ordering could look like this:

    async ValueTask<GridItemsProviderResult<OrderRow>> GetOrders(
        GridItemsProviderRequest request)
    {
        var filtered = db.Orders
            .AsNoTracking()
            .Where(order => order.IsVisible);
    
        var totalCount = await filtered.CountAsync(request.CancellationToken);
    
        if (request.Count is not int count)
        {
            throw new InvalidOperationException("A range size is required.");
        }
    
        var items = await filtered
            .OrderBy(order => order.CreatedAt)
            .ThenBy(order => order.Id)
            .Skip(request.StartIndex)
            .Take(count)
            .Select(order => new OrderRow(order.Id, order.CreatedAt))
            .ToListAsync(request.CancellationToken);
    
        return GridItemsProviderResult.From(items, totalCount);
    }

    This example keeps filtering in the query and uses a unique final key so the ordering is deterministic for a given data state. A production provider also needs to apply the grid’s requested sort. I would map supported sort choices to explicit query expressions rather than treating a client-supplied property name as an arbitrary database instruction; whichever sort is selected, the unique tie-breaker remains. QuickGrid exposes request sorting helpers for applying or inspecting its sort rules (GridItemsProviderRequest API reference).

    Filtering and counting are part of the same promise

    A grid filter changes the sequence being paged. Apply it before both the count and the range query. Counting the unfiltered set while returning filtered rows gives the component a false picture of how much data exists. Applying a filter after retrieving a range is worse: the server may return too few rows, even though matching rows exist just beyond that range.

    The total count is not free. QuickGrid’s provider contract calls for a total item count, and a filtered count can be meaningful work for the database (QuickGrid component documentation). I would count the filtered query, not fetch every matching row just to count them. If exact counts are too expensive for the product’s data shape, that is a design constraint to address explicitly, not a reason to quietly return a made-up total.

    The row query also needs to fit the database’s access pattern. A filter and sort that look simple in C# can still require substantial database work if the query cannot use a suitable index. EF Core’s efficient-querying guidance notes that indexes and their column order matter to query performance, and that offset-based pagination can become inefficient for large offsets (EF Core efficient querying guidance). I would inspect the actual generated SQL and query plan, then design indexes around the filters and ordering the grid really uses. Virtualization reduces the number of rows sent to the browser; it does not guarantee that the database can find those rows cheaply.

    Offset ranges and live data have a cost

    QuickGrid’s virtualization request is expressed as a start index and count. That is a natural fit for offset pagination, but an offset query may need to pass over earlier rows to reach a distant range. EF Core recommends keyset pagination for sequential next-or-previous navigation, where the next query can continue from the last row’s sort keys instead of skipping an offset (EF Core pagination guidance).

    I would not assume keyset pagination is a drop-in replacement for a virtualized grid. A scrollbar can request a range far from the current one; a cursor that only knows the previous row does not identify that arbitrary position. If the product needs both arbitrary scrolling and large datasets, the access pattern deserves deliberate design: perhaps offset ranges are acceptable for the expected use, or perhaps the product should constrain navigation, maintain another way to locate ranges, or change the interaction. The important part is to choose knowingly rather than discover deep-offset costs after the grid ships.

    Stable ordering also does not freeze the dataset. If rows are inserted or removed between requests, a later offset can refer to a shifted sequence even when the sort is deterministic. I would define whether the grid is a live view that may shift while data changes, or whether the user expects a consistent browsing session. That choice affects how the application refreshes data and whether it needs a stronger snapshot boundary. It is a product-level behavior, not something rendering fewer rows can decide.

    Cancellation and query shape belong in the provider

    Scrolling, sorting, and filtering can make an earlier request obsolete before its database query finishes. QuickGrid’s request includes a cancellation token, and I would pass it through database operations rather than allowing stale work to pile up (GridItemsProviderRequest API reference). Cancellation does not replace query limits or indexing, but it lets the data source respond to the grid’s changing needs instead of treating every request as equally current.

    The useful mental model is not “turn on virtualization and fetch fewer rows.” It is “the grid issues range requests, and the server defines a stable, affordable meaning for each one.” I would ship virtualization only after the sort, filter, count, mutation, and range semantics are clear. Otherwise the browser may do less work while the database does more—and the user still cannot trust what the grid shows.

    Drafted with AI assistance, reviewed and edited by Ali.