Turbocharge your strings - a case of display channels

Sep 14, 2025 by Stefan Holm Olsen

During a recent CMS 12 upgrade, I decided to benchmark the existing DisplayChannel implementation. It was actually made by me, and was at the time quite efficient, yet simple.

But since .NET 9 introduced the concept of SearchValues, I decided to benchmark a new variant of the display channel implementation, based on SearchValues. And boy was it faster.

Old solution

The old solution contained a detection class with a method for each display channel: desktop, mobile and tablet.

using System;
using System.Collections.Generic;
using System.Linq;

namespace StefanOlsen.Web.Rendering;

public static class DeviceDetectionHelper
{
    private static readonly ICollection<string> _mobileKeywords =
    [
        "iphone", "mobile", "blackberry", "phone", "smartphone", "webos", "ipod", "lge vx", "midp", "maemo", "mmp",
        "netfront", "hiptop", "nintendo DS", "novarra", "openweb", "opera mobi", "opera mini", "palm", "psp",
        "smartphone", "symbian", "up.browser", "up.link", "wap", "windows ce", "windows phone"
    ];

    private static readonly ICollection<string> _tabletKeywords =
    [
        "tablet", "ipad", "playbook", "hp-tablet", "kindle", "sm-t", "kfauwi"
    ];

public static bool IsDesktop(string userAgent) =>
        !IsMobile(userAgent) && !IsTablet(userAgent);

    public static bool IsMobile(string userAgent) =>
        ContainsAny(userAgent, _mobileKeywords);

    public static bool IsTablet(string userAgent) =>
        ContainsAny(userAgent, _tabletKeywords);

    private static bool ContainsAny(string text, ICollection<string> values) =>
        !string.IsNullOrEmpty(text) &&
        values.Any(value => text.Contains(value, StringComparison.InvariantCultureIgnoreCase));

}

I would then have a display channel class for each of those, basically just calling the relevant method on the detection class.

using EPiServer.ServiceLocation;
using EPiServer.Web;
using Microsoft.AspNetCore.Http;

namespace StefanOlsen.Web.Rendering;

[ServiceConfiguration(typeof(DesktopChannel))]
public class DesktopChannel : DisplayChannel
{
    public override string ChannelName => "Desktop";

    public override bool IsActive(HttpContext context) =>
        DeviceDetectionHelper.IsDesktop((string)context.Request.Headers.UserAgent);
}

[ServiceConfiguration(typeof(MobileChannel))]
public class MobileChannel : DisplayChannel
{
    public override string ChannelName => "Mobile";

    public override bool IsActive(HttpContext context) =>
        DeviceDetectionHelper.IsMobile((string)context.Request.Headers.UserAgent);
}

[ServiceConfiguration(typeof(TabletChannel))]
public class TabletChannel : DisplayChannel
{
    public override string ChannelName => "Tablet";

    public override bool IsActive(HttpContext context) =>
        DeviceDetectionHelper.IsTablet((string)context.Request.Headers.UserAgent);
}

The old solution is slower the farther through the list the code runs. Even slower if the result is going to be false. And slowest if the user agent is neither mobile or tablet, as it would need to ensure there are no matches.

New solution

The new solution is a re-write of the inner workings of the detection class. The display channels remain the same. The only change to them is adding .AsSpan() after .UserAgent in the method calls.

using System;
using System.Buffers;

namespace StefanOlsen.Web.Rendering;

public static class DeviceDetectionHelper
{
    private static readonly SearchValues<string> MobileKeywords = SearchValues.Create([
        "iphone", "mobile", "blackberry", "phone", "smartphone", "webos", "ipod", "lge vx", "midp", "maemo", "mmp",
        "netfront", "hiptop", "nintendo DS", "novarra", "openweb", "opera mobi", "opera mini", "palm", "psp",
        "smartphone", "symbian", "up.browser", "up.link", "wap", "windows ce", "windows phone"
    ], StringComparison.OrdinalIgnoreCase);

    private static readonly SearchValues<string> TabletKeywords = SearchValues.Create(
    [
        "tablet", "ipad", "playbook", "hp-tablet", "kindle", "sm-t", "kfauwi"
    ], StringComparison.OrdinalIgnoreCase);
    
    public static bool IsDesktop(ReadOnlySpan<char> userAgent) =>
        userAgent.IsEmpty || (!userAgent.ContainsAny(MobileKeywords) && !userAgent.ContainsAny(TabletKeywords));

    public static bool IsMobile(ReadOnlySpan<char> userAgent) =>
        !userAgent.IsEmpty && userAgent.ContainsAny(MobileKeywords);

    public static bool IsTablet(ReadOnlySpan<char> userAgent) =>
        !userAgent.IsEmpty && userAgent.ContainsAny(TabletKeywords);
}

The new solution, on the other hand, is consistently fast, regardless of whether the result is true or false.

What is really cool is that SearchValues will attempt to use the most efficient algorithm based on the type and number of values, and based on the kind of CPU available at runtime.

If we run this on a new AMD64 processor, then it might use AVX512. But on my MacBook Pro M3, the runtime use AdvSIMD instructions. In short it will always give the best possible performance. It is a very fascinating library.

So in theory these display channels should be the least performance concern in such site. Now we can move off to improve something else.

Alternative solutions

During the process I benchmarked some other solutions based on:

  • Contains with ordinal string comparison. This was much faster than the invariant comparison.
  • Use Array.Exists. This was slightly faster than the .Any LINQ method and allocated less memory.
  • Use a source-generated Regex. This was somewhat fast when the user agent contains one of the first keywords. Not so much when the user agent didn’t match anything.

Benchmark results

Here are the results of the benchmarks for a mobile user agent.

Method UserAgent Mean Ratio Allocated
Original Mozi(...)gfe) [145] 255.82 ns 1.00 104 B
Original with ordinal comparer Mozi(...)gfe) [145] 41.99 ns 0.16 104 B
Array.Exists Mozi(...)gfe) [145] 29.25 ns 0.11 64 B
GeneratedRegex Mozi(...)gfe) [145] 64.01 ns 0.25 -
SearchValues Mozi(...)gfe) [145] 12.06 ns 0.05 -

And here are the results for a desktop user agent. The numbers are considerably higher for all other solutions than the new one based on SearchValues.

Method UserAgent Mean Ratio Allocated
Original Mozi(...)1.15 [117] 3,666.79 ns 1.00 104 B
Original with ordinal comparer Mozi(...)1.15 [117] 370.73 ns 0.101 104 B
Array.Exists Mozi(...)1.15 [117] 285.75 ns 0.078 64 B
GeneratedRegex Mozi(...)1.15 [117] 553.72 ns 0.151 -
SearchValues Mozi(...)1.15 [117] 14.07 ns 0.004 -

In short, the new approach is around 95% faster in both scenarios. And they both eliminate memory allocations completely.

Summing up

SearchValues is a great new tool for searching a string for one or more substrings.

However, for pattern searches we will still need to use RegEx. But even when using GeneratedRegex (in .NET9+), SearchValues may sneak its way in. Specifically when the regular expression contains character ranges.

Can you think of code pieces that can benefit from SearchValues?

.Net Performance Optimizely Optimizely CMS