diff --git a/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs b/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs
index cc9cea511788..d4302c136ffd 100644
--- a/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs
@@ -21,7 +21,7 @@ public class ApplyCompressionNegotiation : Task
 
     public override bool Execute()
     {
-        var assetsById = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToDictionary(a => a.Identity);
+        var assetsById = StaticWebAsset.ToAssetDictionary(CandidateAssets);
 
         var endpointsByAsset = CandidateEndpoints.Select(StaticWebAssetEndpoint.FromTaskItem)
             .GroupBy(e => e.AssetFile)
diff --git a/src/StaticWebAssetsSdk/Tasks/CollectStaticWebAssetsToCopy.cs b/src/StaticWebAssetsSdk/Tasks/CollectStaticWebAssetsToCopy.cs
index 6190bed80c21..9b3f235bb69f 100644
--- a/src/StaticWebAssetsSdk/Tasks/CollectStaticWebAssetsToCopy.cs
+++ b/src/StaticWebAssetsSdk/Tasks/CollectStaticWebAssetsToCopy.cs
@@ -25,7 +25,7 @@ public override bool Execute()
         var normalizedOutputPath = StaticWebAsset.NormalizeContentRootPath(Path.GetFullPath(OutputPath));
         try
         {
-            foreach (var asset in Assets.Select(StaticWebAsset.FromTaskItem))
+            foreach (var asset in StaticWebAsset.FromTaskItemGroup(Assets))
             {
                 string fileOutputPath = null;
                 if (!(asset.IsDiscovered() || asset.IsComputed()))
diff --git a/src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs b/src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs
index d3f4c5ee87b0..8c3ed69a038d 100644
--- a/src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs
+++ b/src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs
@@ -29,7 +29,7 @@ public override bool Execute()
             return true;
         }
 
-        var candidates = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray();
+        var candidates = StaticWebAsset.FromTaskItemGroup(CandidateAssets);
         var assetsToUpdate = new List<ITaskItem>();
 
         var candidatesByIdentity = candidates.ToDictionary(asset => asset.Identity, OSPath.PathComparer);
diff --git a/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs b/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs
index e1c61a6b9e05..c3f34546bd31 100644
--- a/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs
+++ b/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs
@@ -54,8 +54,8 @@ public override bool Execute()
             return true;
         }
 
-        var candidates = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray();
-        var explicitAssets = ExplicitAssets?.Select(StaticWebAsset.FromTaskItem).ToArray() ?? [];
+        var candidates = StaticWebAsset.FromTaskItemGroup(CandidateAssets).ToArray();
+        var explicitAssets = ExplicitAssets == null ? [] : StaticWebAsset.FromTaskItemGroup(ExplicitAssets);
         var existingCompressionFormatsByAssetItemSpec = CollectCompressedAssets(candidates);
 
         var includePatterns = SplitPattern(IncludePatterns);
diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs
index cc6d09cc5101..f7ad1954acab 100644
--- a/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs
@@ -20,7 +20,7 @@ public class ComputeEndpointsForReferenceStaticWebAssets : Task
 
     public override bool Execute()
     {
-        var assets = Assets.Select(StaticWebAsset.FromTaskItem).ToDictionary(a => a.Identity, a => a);
+        var assets = StaticWebAsset.ToAssetDictionary(Assets);
         var candidateEndpoints = StaticWebAssetEndpoint.FromItemGroup(CandidateEndpoints);
 
         var endpoints = new List<StaticWebAssetEndpoint>();
diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs b/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs
index c25f701b06ed..18cda8b9e90c 100644
--- a/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs
@@ -38,28 +38,19 @@ public override bool Execute()
     {
         try
         {
-            var existingAssets = Assets
-                .Where(asset => StaticWebAsset.HasSourceId(asset, Source))
-                .Select(StaticWebAsset.FromTaskItem)
-                .GroupBy(
-                    a => a.ComputeTargetPath("", '/'),
-                    (key, group) => (key, StaticWebAsset.ChooseNearestAssetKind(group, AssetKind)));
-
-            var resultAssets = new List<StaticWebAsset>();
-            foreach (var (key, group) in existingAssets)
+            var existingAssets = StaticWebAsset.AssetsByTargetPath(Assets, Source, AssetKind);
+
+            var resultAssets = new List<StaticWebAsset>(existingAssets.Count);
+            foreach (var kvp in existingAssets)
             {
-                if (!TryGetUniqueAsset(group, out var selected))
+                var targetPath = kvp.Key;
+                var (selected, all) = kvp.Value;
+                if (all != null)
                 {
-                    if (selected == null)
-                    {
-                        Log.LogMessage(MessageImportance.Low, "No compatible asset found for '{0}'", key);
-                        continue;
-                    }
-                    else
-                    {
-                        Log.LogError("More than one compatible asset found for '{0}'.", selected.Identity);
-                        return false;
-                    }
+                    Log.LogError("More than one compatible asset found for target path '{0}' -> {1}.",
+                        targetPath,
+                        Environment.NewLine + string.Join(Environment.NewLine, all.Select(a => $"({a.Identity},{a.AssetKind})")));
+                    return false;
                 }
 
                 if (ShouldIncludeAssetAsReference(selected, out var reason))
@@ -106,22 +97,6 @@ public override bool Execute()
         return !Log.HasLoggedErrors;
     }
 
-    private static bool TryGetUniqueAsset(IEnumerable<StaticWebAsset> candidates, out StaticWebAsset selected)
-    {
-        selected = null;
-        foreach (var asset in candidates)
-        {
-            if (selected != null)
-            {
-                return false;
-            }
-
-            selected = asset;
-        }
-
-        return selected != null;
-    }
-
     private bool ShouldIncludeAssetAsReference(StaticWebAsset candidate, out string reason)
     {
         if (!StaticWebAssetsManifest.ManifestModes.ShouldIncludeAssetAsReference(candidate, ProjectMode))
diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs b/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs
index 0df5b4210b44..3c4d49582d2e 100644
--- a/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs
@@ -28,28 +28,19 @@ public override bool Execute()
     {
         try
         {
-            var currentProjectAssets = Assets
-                .Where(asset => StaticWebAsset.HasSourceId(asset, Source))
-                .Select(StaticWebAsset.FromTaskItem)
-                .GroupBy(
-                    a => a.ComputeTargetPath("", '/'),
-                    (key, group) => (key, StaticWebAsset.ChooseNearestAssetKind(group, AssetKind)));
+            var currentProjectAssets = StaticWebAsset.AssetsByTargetPath(Assets, Source, AssetKind);
 
-            var resultAssets = new List<StaticWebAsset>();
-            foreach (var (key, group) in currentProjectAssets)
+            var resultAssets = new List<StaticWebAsset>(currentProjectAssets.Count);
+            foreach (var kvp in currentProjectAssets)
             {
-                if (!TryGetUniqueAsset(group, out var selected))
+                var targetPath = kvp.Key;
+                var (selected, all) = kvp.Value;
+                if (all != null)
                 {
-                    if (selected == null)
-                    {
-                        Log.LogMessage(MessageImportance.Low, "No compatible asset found for '{0}'", key);
-                        continue;
-                    }
-                    else
-                    {
-                        Log.LogError("More than one compatible asset found for '{0}'.", selected.Identity);
-                        return false;
-                    }
+                    Log.LogError("More than one compatible asset found for target path '{0}' -> {1}.",
+                        targetPath,
+                        Environment.NewLine + string.Join(Environment.NewLine, all.Select(a => $"({a.Identity},{a.AssetKind})")));
+                    return false;
                 }
 
                 if (!selected.IsForReferencedProjectsOnly())
@@ -74,20 +65,4 @@ public override bool Execute()
 
         return !Log.HasLoggedErrors;
     }
-
-    private static bool TryGetUniqueAsset(IEnumerable<StaticWebAsset> candidates, out StaticWebAsset selected)
-    {
-        selected = null;
-        foreach (var asset in candidates)
-        {
-            if (selected != null)
-            {
-                return false;
-            }
-
-            selected = asset;
-        }
-
-        return selected != null;
-    }
 }
diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsTargetPaths.cs b/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsTargetPaths.cs
index 9f1db9f6de8b..cf3203106fc2 100644
--- a/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsTargetPaths.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsTargetPaths.cs
@@ -4,7 +4,6 @@
 #nullable disable
 
 using Microsoft.Build.Framework;
-using Microsoft.Build.Utilities;
 
 namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
@@ -27,7 +26,7 @@ public override bool Execute()
         try
         {
             Log.LogMessage(MessageImportance.Low, "Using path prefix '{0}'", PathPrefix);
-            AssetsWithTargetPath = new TaskItem[Assets.Length];
+            AssetsWithTargetPath = new ITaskItem[Assets.Length];
 
             for (var i = 0; i < Assets.Length; i++)
             {
diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs
index f2e518eda5aa..60058355f0b6 100644
--- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs
+++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs
@@ -3,99 +3,337 @@
 
 #nullable disable
 
+using System.Collections;
 using System.Diagnostics;
 using System.Globalization;
 using System.Security.Cryptography;
 using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;
 using Microsoft.Build.Framework;
-using Microsoft.Build.Utilities;
 
 namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
 [DebuggerDisplay("{" + nameof(GetDebuggerDisplay) + "(),nq}")]
 #if WASM_TASKS
-internal sealed class StaticWebAsset : IEquatable<StaticWebAsset>, IComparable<StaticWebAsset>
+internal sealed class StaticWebAsset : IEquatable<StaticWebAsset>, IComparable<StaticWebAsset>, ITaskItem2
 #else
-public sealed class StaticWebAsset : IEquatable<StaticWebAsset>, IComparable<StaticWebAsset>
+public sealed class StaticWebAsset : IEquatable<StaticWebAsset>, IComparable<StaticWebAsset>, ITaskItem2
 #endif
 {
     public const string DateTimeAssetFormat = "ddd, dd MMM yyyy HH:mm:ss 'GMT'";
 
+    private bool _modified;
+    private ITaskItem _originalItem;
+    private string _identity;
+    private string _sourceId;
+    private string _sourceType;
+    private string _contentRoot;
+    private string _basePath;
+    private string _relativePath;
+    private string _assetKind;
+    private string _assetMode;
+    private string _assetRole;
+    private string _assetMergeBehavior;
+    private string _assetMergeSource;
+    private string _relatedAsset;
+    private string _assetTraitName;
+    private string _assetTraitValue;
+    private string _fingerprint;
+    private string _integrity;
+    private string _copyToOutputDirectory;
+    private string _copyToPublishDirectory;
+    private string _originalItemSpec;
+    private long _fileLength = -1;
+    private DateTimeOffset _lastWriteTime = DateTimeOffset.MinValue;
+    private Dictionary<string, string> _additionalCustomMetadata;
+    private string _fileLengthString;
+    private string _lastWriteTimeString;
+
     public StaticWebAsset()
     {
     }
 
     public StaticWebAsset(StaticWebAsset asset)
     {
-        Identity = asset.Identity;
-        SourceType = asset.SourceType;
-        SourceId = asset.SourceId;
-        ContentRoot = asset.ContentRoot;
-        BasePath = asset.BasePath;
-        RelativePath = asset.RelativePath;
-        AssetKind = asset.AssetKind;
-        AssetMode = asset.AssetMode;
-        AssetRole = asset.AssetRole;
-        AssetMergeBehavior = asset.AssetMergeBehavior;
-        AssetMergeSource = asset.AssetMergeSource;
-        RelatedAsset = asset.RelatedAsset;
-        AssetTraitName = asset.AssetTraitName;
-        AssetTraitValue = asset.AssetTraitValue;
-        CopyToOutputDirectory = asset.CopyToOutputDirectory;
-        CopyToPublishDirectory = asset.CopyToPublishDirectory;
-        OriginalItemSpec = asset.OriginalItemSpec;
-        FileLength = asset.FileLength;
-        LastWriteTime = asset.LastWriteTime;
+        _identity = asset.Identity;
+        _sourceType = asset.SourceType;
+        _sourceId = asset.SourceId;
+        _contentRoot = asset.ContentRoot;
+        _basePath = asset.BasePath;
+        _relativePath = asset.RelativePath;
+        _assetKind = asset.AssetKind;
+        _assetMode = asset.AssetMode;
+        _assetRole = asset.AssetRole;
+        _assetMergeBehavior = asset.AssetMergeBehavior;
+        _assetMergeSource = asset.AssetMergeSource;
+        _relatedAsset = asset.RelatedAsset;
+        _assetTraitName = asset.AssetTraitName;
+        _assetTraitValue = asset.AssetTraitValue;
+        _copyToOutputDirectory = asset.CopyToOutputDirectory;
+        _copyToPublishDirectory = asset.CopyToPublishDirectory;
+        _originalItemSpec = asset.OriginalItemSpec;
+        _fileLength = asset.FileLength;
+        _lastWriteTime = asset.LastWriteTime;
+        _fingerprint = asset.Fingerprint;
+        _integrity = asset.Integrity;
     }
 
-    public string Identity { get; set; }
+    private string GetOriginalItemMetadata(string name) => _originalItem?.GetMetadata(name);
 
-    public string SourceId { get; set; }
+    public string Identity
+    {
+        get
+        {
+            return _identity ??=
+                // Register the identity as the full path since assets might have come
+                // from packages and other sources and the identity (which is typically
+                // just the relative path from the project) is not enough to locate them.
+                GetOriginalItemMetadata("FullPath");
+        }
 
-    public string SourceType { get; set; }
+        set
+        {
+            _modified = true;
+            _identity = value;
+        }
+    }
 
-    public string ContentRoot { get; set; }
+    public string SourceId
+    {
+        get => _sourceId ??= GetOriginalItemMetadata(nameof(SourceId));
+        set
+        {
+            _modified = true;
+            _sourceId = value;
+        }
+    }
 
-    public string BasePath { get; set; }
+    public string SourceType
+    {
+        get => _sourceType ??= GetOriginalItemMetadata(nameof(SourceType));
+        set
+        {
+            _modified = true;
+            _sourceType = value;
+        }
+    }
 
-    public string RelativePath { get; set; }
+    public string ContentRoot
+    {
+        get => _contentRoot ??= GetOriginalItemMetadata(nameof(ContentRoot));
+        set
+        {
+            _modified = true;
+            _contentRoot = value;
+        }
+    }
 
-    public string AssetKind { get; set; }
+    public string BasePath
+    {
+        get => _basePath ??= GetOriginalItemMetadata(nameof(BasePath));
+        set
+        {
+            _modified = true;
+            _basePath = value;
+        }
+    }
 
-    public string AssetMode { get; set; }
+    public string RelativePath
+    {
+        get => _relativePath ??= GetOriginalItemMetadata(nameof(RelativePath));
+        set
+        {
+            _modified = true;
+            _relativePath = value;
+        }
+    }
 
-    public string AssetRole { get; set; }
+    public string AssetKind
+    {
+        get => _assetKind ??= GetOriginalItemMetadata(nameof(AssetKind));
+        set
+        {
+            _modified = true;
+            _assetKind = value;
+        }
+    }
 
-    public string AssetMergeBehavior { get; set; }
+    public string AssetMode
+    {
+        get => _assetMode ??= GetOriginalItemMetadata(nameof(AssetMode));
+        set
+        {
+            _modified = true;
+            _assetMode = value;
+        }
+    }
 
-    public string AssetMergeSource { get; set; }
+    public string AssetRole
+    {
+        get => _assetRole ??= GetOriginalItemMetadata(nameof(AssetRole));
+        set
+        {
+            _modified = true;
+            _assetRole = value;
+        }
+    }
 
-    public string RelatedAsset { get; set; }
+    public string AssetMergeBehavior
+    {
+        get => _assetMergeBehavior ??= GetOriginalItemMetadata(nameof(AssetMergeBehavior));
+        set
+        {
+            _modified = true;
+            _assetMergeBehavior = value;
+        }
+    }
 
-    public string AssetTraitName { get; set; }
+    public string AssetMergeSource
+    {
+        get => _assetMergeSource ??= GetOriginalItemMetadata(nameof(AssetMergeSource));
+        set
+        {
+            _modified = true;
+            _assetMergeSource = value;
+        }
+    }
 
-    public string AssetTraitValue { get; set; }
+    public string RelatedAsset
+    {
+        get => _relatedAsset ??= GetOriginalItemMetadata(nameof(RelatedAsset));
+        set
+        {
+            _modified = true;
+            _relatedAsset = value;
+        }
+    }
 
-    public string Fingerprint { get; set; }
+    public string AssetTraitName
+    {
+        get => _assetTraitName ??= GetOriginalItemMetadata(nameof(AssetTraitName));
+        set
+        {
+            _modified = true;
+            _assetTraitName = value;
+        }
+    }
 
-    public string Integrity { get; set; }
+    public string AssetTraitValue
+    {
+        get => _assetTraitValue ??= GetOriginalItemMetadata(nameof(AssetTraitValue));
+        set
+        {
+            _modified = true;
+            _assetTraitValue = value;
+        }
+    }
 
-    public string CopyToOutputDirectory { get; set; }
+    public string Fingerprint
+    {
+        get => _fingerprint ??= GetOriginalItemMetadata(nameof(Fingerprint));
+        set
+        {
+            _modified = true;
+            _fingerprint = value;
+        }
+    }
 
-    public string CopyToPublishDirectory { get; set; }
+    public string Integrity
+    {
+        get => _integrity ??= GetOriginalItemMetadata(nameof(Integrity));
+        set
+        {
+            _modified = true;
+            _integrity = value;
+        }
+    }
 
-    public string OriginalItemSpec { get; set; }
+    public string CopyToOutputDirectory
+    {
+        get => _copyToOutputDirectory ??= GetOriginalItemMetadata(nameof(CopyToOutputDirectory));
+        set
+        {
+            _modified = true;
+            _copyToOutputDirectory = value;
+        }
+    }
 
-    public long FileLength { get; set; } = -1;
+    public string CopyToPublishDirectory
+    {
+        get => _copyToPublishDirectory ??= GetOriginalItemMetadata(nameof(CopyToPublishDirectory));
+        set
+        {
+            _modified = true;
+            _copyToPublishDirectory = value;
+        }
+    }
 
-    public DateTimeOffset LastWriteTime { get; set; } = DateTimeOffset.MinValue;
+    public string OriginalItemSpec
+    {
+        get => _originalItemSpec ??= GetOriginalItemMetadata(nameof(OriginalItemSpec));
+        set
+        {
+            _modified = true;
+            _originalItemSpec = value;
+        }
+    }
+
+    internal string FileLengthString => _fileLengthString ??= GetOriginalItemMetadata(nameof(FileLength));
 
-    public static StaticWebAsset FromTaskItem(ITaskItem item)
+    public long FileLength
+    {
+        get
+        {
+            if (_fileLength < 0)
+            {
+                var fileLengthString = FileLengthString;
+                _fileLength = !string.IsNullOrEmpty(fileLengthString) &&
+                    long.TryParse(fileLengthString, NumberStyles.None, CultureInfo.InvariantCulture, out var fileLength)
+                    ? fileLength
+                    : -1;
+            }
+            return _fileLength;
+        }
+        set
+        {
+            _fileLengthString = null;
+            _modified = true;
+            _fileLength = value;
+        }
+    }
+
+    internal string LastWriteTimeString => _lastWriteTimeString ??= GetOriginalItemMetadata(nameof(LastWriteTime));
+
+    public DateTimeOffset LastWriteTime
+    {
+        get
+        {
+            if (_lastWriteTime == DateTimeOffset.MinValue)
+            {
+                var lastWriteTimeString = LastWriteTimeString;
+                _lastWriteTime = !string.IsNullOrEmpty(lastWriteTimeString) &&
+                    DateTimeOffset.TryParse(lastWriteTimeString, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastWriteTime)
+                    ? lastWriteTime
+                    : DateTimeOffset.MinValue;
+            }
+            return _lastWriteTime;
+        }
+        set
+        {
+            _lastWriteTimeString = null;
+            _modified = true;
+            _lastWriteTime = value;
+        }
+    }
+
+    public static StaticWebAsset FromTaskItem(ITaskItem item, bool validate = false)
     {
         var result = FromTaskItemCore(item);
 
         result.Normalize();
-        result.Validate();
+        if (validate)
+        {
+            result.Validate();
+        }
 
         return result;
     }
@@ -119,14 +357,20 @@ internal static IEnumerable<StaticWebAsset> ChooseNearestAssetKind(IEnumerable<S
         {
             if (item.HasKind(assetKind))
             {
+                // The moment we find a Build or Publish asset, we start ignoring the
+                // All assets
                 ignoreAllKind = true;
 
+                // We still return multiple Build or Publish items if they are present
+                // But we won't error out if there are multiple All assets as long as there
+                // is a single Build or Publish asset present.
                 yield return item;
             }
             else if (!ignoreAllKind && item.IsBuildAndPublish())
             {
                 if (allKindAssetCandidate != null)
                 {
+                    // At this point we have more than one `All` asset, which is an error
                     yield return allKindAssetCandidate;
                     yield return item;
                     yield break;
@@ -141,42 +385,91 @@ internal static IEnumerable<StaticWebAsset> ChooseNearestAssetKind(IEnumerable<S
         }
     }
 
-    internal static bool ValidateAssetGroup(string path, IReadOnlyList<StaticWebAsset> group, out string reason)
+    internal static bool ValidateAssetGroup(string path, (StaticWebAsset First, StaticWebAsset Second, IReadOnlyList<StaticWebAsset> Others) group, out string reason)
     {
-        StaticWebAsset prototypeItem = null;
+        var prototypeItem = group.First;
         StaticWebAsset build = null;
         StaticWebAsset publish = null;
         StaticWebAsset all = null;
-        foreach (var item in group)
+
+        if (group.Second == null)
+        {
+            // Most common case, only one asset for the given path
+            reason = null;
+            return true;
+        }
+
+        // Check First against Second for source ID conflict
+        if (!prototypeItem.HasSourceId(group.Second.SourceId))
+        {
+            reason = $"Conflicting assets with the same target path '{path}'. For assets '{prototypeItem}' and '{group.Second}' from different projects.";
+            return false;
+        }
+
+        // Process First
+        build = group.First.IsBuildOnly() ? group.First : build;
+        publish = group.First.IsPublishOnly() ? group.First : publish;
+        all = group.First.IsBuildAndPublish() ? group.First : all;
+
+        // Process Second
+        if (build != null && group.Second.IsBuildOnly() && !ReferenceEquals(build, group.Second))
+        {
+            reason = $"Conflicting assets with the same target path '{path}'. For 'Build' assets '{build}' and '{group.Second}'.";
+            return false;
+        }
+        build ??= group.Second.IsBuildOnly() ? group.Second : build;
+
+        if (publish != null && group.Second.IsPublishOnly() && !ReferenceEquals(publish, group.Second))
+        {
+            reason = $"Conflicting assets with the same target path '{path}'. For 'Publish' assets '{publish}' and '{group.Second}'.";
+            return false;
+        }
+        publish ??= group.Second.IsPublishOnly() ? group.Second : publish;
+
+        if (all != null && group.Second.IsBuildAndPublish() && !ReferenceEquals(all, group.Second))
+        {
+            reason = $"Conflicting assets with the same target path '{path}'. For 'All' assets '{all}' and '{group.Second}'.";
+            return false;
+        }
+        all ??= group.Second.IsBuildAndPublish() ? group.Second : all;
+
+        if (group.Others == null || group.Others.Count == 0)
+        {
+            reason = null;
+            return true;
+        }
+
+        // Process rest of the items
+        foreach (var item in group.Others)
         {
-            prototypeItem ??= item;
             if (!prototypeItem.HasSourceId(item.SourceId))
             {
                 reason = $"Conflicting assets with the same target path '{path}'. For assets '{prototypeItem}' and '{item}' from different projects.";
                 return false;
             }
 
-            build ??= item.IsBuildOnly() ? item : build;
             if (build != null && item.IsBuildOnly() && !ReferenceEquals(build, item))
             {
                 reason = $"Conflicting assets with the same target path '{path}'. For 'Build' assets '{build}' and '{item}'.";
                 return false;
             }
+            build ??= item.IsBuildOnly() ? item : build;
 
-            publish ??= item.IsPublishOnly() ? item : publish;
             if (publish != null && item.IsPublishOnly() && !ReferenceEquals(publish, item))
             {
                 reason = $"Conflicting assets with the same target path '{path}'. For 'Publish' assets '{publish}' and '{item}'.";
                 return false;
             }
+            publish ??= item.IsPublishOnly() ? item : publish;
 
-            all ??= item.IsBuildAndPublish() ? item : all;
             if (all != null && item.IsBuildAndPublish() && !ReferenceEquals(all, item))
             {
                 reason = $"Conflicting assets with the same target path '{path}'. For 'All' assets '{all}' and '{item}'.";
                 return false;
             }
+            all ??= item.IsBuildAndPublish() ? item : all;
         }
+
         reason = null;
         return true;
     }
@@ -196,36 +489,13 @@ public static StaticWebAsset FromV1TaskItem(ITaskItem item)
         return result;
     }
 
-    private static StaticWebAsset FromTaskItemCore(ITaskItem item) =>
-        new()
-        {
-            // Register the identity as the full path since assets might have come
-            // from packages and other sources and the identity (which is typically
-            // just the relative path from the project) is not enough to locate them.
-            Identity = item.GetMetadata("FullPath"),
-            SourceType = item.GetMetadata(nameof(SourceType)),
-            SourceId = item.GetMetadata(nameof(SourceId)),
-            ContentRoot = item.GetMetadata(nameof(ContentRoot)),
-            BasePath = item.GetMetadata(nameof(BasePath)),
-            RelativePath = item.GetMetadata(nameof(RelativePath)),
-            AssetKind = item.GetMetadata(nameof(AssetKind)),
-            AssetMode = item.GetMetadata(nameof(AssetMode)),
-            AssetRole = item.GetMetadata(nameof(AssetRole)),
-            AssetMergeSource = item.GetMetadata(nameof(AssetMergeSource)),
-            AssetMergeBehavior = item.GetMetadata(nameof(AssetMergeBehavior)),
-            RelatedAsset = item.GetMetadata(nameof(RelatedAsset)),
-            AssetTraitName = item.GetMetadata(nameof(AssetTraitName)),
-            AssetTraitValue = item.GetMetadata(nameof(AssetTraitValue)),
-            Fingerprint = item.GetMetadata(nameof(Fingerprint)),
-            Integrity = item.GetMetadata(nameof(Integrity)),
-            CopyToOutputDirectory = item.GetMetadata(nameof(CopyToOutputDirectory)),
-            CopyToPublishDirectory = item.GetMetadata(nameof(CopyToPublishDirectory)),
-            OriginalItemSpec = item.GetMetadata(nameof(OriginalItemSpec)),
-            FileLength = item.GetMetadata("FileLength") is string fileLengthString &&
-                long.TryParse(fileLengthString, out var fileLength) ? fileLength : -1,
-            LastWriteTime = item.GetMetadata("LastWriteTime") is string lastWriteTimeString &&
-                DateTimeOffset.TryParse(lastWriteTimeString, out var lastWriteTime) ? lastWriteTime : DateTimeOffset.MinValue
+    private static StaticWebAsset FromTaskItemCore(ITaskItem item)
+    {
+        return new()
+        {
+            _originalItem = item,
         };
+    }
 
     public void ApplyDefaults()
     {
@@ -301,28 +571,15 @@ public static string CombineNormalizedPaths(string prefix, string basePath, stri
 
     public ITaskItem ToTaskItem()
     {
-        var result = new TaskItem(Identity);
-        result.SetMetadata(nameof(SourceType), SourceType);
-        result.SetMetadata(nameof(SourceId), SourceId);
-        result.SetMetadata(nameof(ContentRoot), ContentRoot);
-        result.SetMetadata(nameof(BasePath), BasePath);
-        result.SetMetadata(nameof(RelativePath), RelativePath);
-        result.SetMetadata(nameof(AssetKind), AssetKind);
-        result.SetMetadata(nameof(AssetMode), AssetMode);
-        result.SetMetadata(nameof(AssetRole), AssetRole);
-        result.SetMetadata(nameof(AssetMergeSource), AssetMergeSource);
-        result.SetMetadata(nameof(AssetMergeBehavior), AssetMergeBehavior);
-        result.SetMetadata(nameof(RelatedAsset), RelatedAsset);
-        result.SetMetadata(nameof(AssetTraitName), AssetTraitName);
-        result.SetMetadata(nameof(AssetTraitValue), AssetTraitValue);
-        result.SetMetadata(nameof(Fingerprint), Fingerprint);
-        result.SetMetadata(nameof(Integrity), Integrity);
-        result.SetMetadata(nameof(CopyToOutputDirectory), CopyToOutputDirectory);
-        result.SetMetadata(nameof(CopyToPublishDirectory), CopyToPublishDirectory);
-        result.SetMetadata(nameof(OriginalItemSpec), OriginalItemSpec);
-        result.SetMetadata("FileLength", FileLength.ToString(CultureInfo.InvariantCulture));
-        result.SetMetadata("LastWriteTime", LastWriteTime.ToString(DateTimeAssetFormat, CultureInfo.InvariantCulture));
-        return result;
+        if (!_modified && _originalItem != null)
+        {
+            // We haven't modified the item, we can just return the original item.
+            // This is still interesting because MSBuild can optimize things and avoid
+            // additional copies.
+            return _originalItem;
+        }
+        // We can always return ourselves, any property that wasn't modified we will copy from the original item if exists.
+        return this;
     }
 
     public void Validate()
@@ -467,7 +724,7 @@ internal static StaticWebAsset FromProperties(
             CopyToPublishDirectory = copyToPublishDirectory,
             OriginalItemSpec = originalItemSpec,
             FileLength = fileLength,
-            LastWriteTime = lastWriteTime
+            LastWriteTime = lastWriteTime,
         };
 
         result.ApplyDefaults();
@@ -971,6 +1228,339 @@ internal static FileInfo ResolveFile(string identity, string originalItemSpec)
         throw new InvalidOperationException($"No file exists for the asset at either location '{identity}' or '{originalItemSpec}'.");
     }
 
+    internal static Dictionary<string, StaticWebAsset> ToAssetDictionary(ITaskItem[] candidateAssets, bool validate = false)
+    {
+        var dictionary = new Dictionary<string, StaticWebAsset>(candidateAssets.Length);
+        for (var i = 0; i < candidateAssets.Length; i++)
+        {
+            var candidateAsset = FromTaskItem(candidateAssets[i], validate);
+            dictionary.Add(candidateAsset.Identity, candidateAsset);
+        }
+
+        return dictionary;
+    }
+
+    internal static StaticWebAsset[] FromTaskItemGroup(ITaskItem[] candidateAssets, bool validate = false)
+    {
+        var result = new StaticWebAsset[candidateAssets.Length];
+        for (var i = 0; i != result.Length; i++)
+        {
+            var candidateAsset = FromTaskItem(candidateAssets[i], validate);
+            result[i] = candidateAsset;
+        }
+        return result;
+    }
+
+    internal static Dictionary<string, (StaticWebAsset, List<StaticWebAsset>)> AssetsByTargetPath(ITaskItem[] assets, string source, string assetKind)
+    {
+        // We return either the selected asset or a list with all the candidates that were found to be ambiguous
+        var result = new Dictionary<string, (StaticWebAsset selected, List<StaticWebAsset> all)>();
+        for (var i = 0; i < assets.Length; i++)
+        {
+            var candidate = assets[i];
+            if (!HasSourceId(candidate, source))
+            {
+                continue;
+            }
+            if (HasOppositeKind(candidate, assetKind))
+            {
+                continue;
+            }
+            var asset = FromTaskItem(candidate);
+            var key = asset.ComputeTargetPath("", '/');
+            if (!result.TryGetValue(key, out var existing))
+            {
+                result[key] = (asset, null);
+            }
+            else
+            {
+                var (existingAsset, all) = existing;
+                if (existingAsset == null)
+                {
+                    Debug.Assert(all != null);
+                    // We are going to error out, just add to the list
+                    all.Add(asset);
+                }
+                else if (existingAsset.AssetKind == asset.AssetKind)
+                {
+                    // We have an ambiguity because there are either two Build, Publish or All assets
+                    result[key] = (null, [existingAsset, asset]);
+                }
+                else if (existingAsset.IsBuildAndPublish() && !asset.IsBuildAndPublish())
+                {
+                    // There is an All asset overriden by a Build or Publish asset.
+                    result[key] = (asset, null);
+                }
+            }
+        }
+        return result;
+    }
+
+    private static bool HasOppositeKind(ITaskItem candidate, string assetKind)
+    {
+        var candidateKind = candidate.GetMetadata(nameof(AssetKind));
+        return candidateKind switch
+        {
+            AssetKinds.Publish => assetKind switch
+            {
+                AssetKinds.Build => true,
+                _ => false,
+            },
+            AssetKinds.Build => assetKind switch
+            {
+                AssetKinds.Publish => true,
+                _ => false,
+            },
+            _ => false
+        };
+    }
+
+    // We provide the minimal ITaskItem2 implementation so that we can return StaticWebAsset instances without having to convert them
+    // to task items. This is because the underlying implementation uses an immutable dictionary and every call to SetMetadata results
+    // in a new allocation.
+    // When the task returns, MSBuild will convert the task into a ProjectItem instance and will copy the custom metadata, at which point
+    // it will get rid of the instance that we returned and won't use it any longer.
+    // For that reason, and since we control inside the tasks how this is used, we can safely ignore the pieces that MSBuild won't call.
+    #region ITaskItem2 implementation
+
+    string ITaskItem2.EvaluatedIncludeEscaped { get => Identity; set => Identity = value; }
+    string ITaskItem.ItemSpec { get => Identity; set => Identity = value; }
+
+    private static readonly string[] _defaultPropertyNames = [
+        nameof(SourceId),
+        nameof(SourceType),
+        nameof(ContentRoot),
+        nameof(BasePath),
+        nameof(RelativePath),
+        nameof(AssetKind),
+        nameof(AssetMode),
+        nameof(AssetRole),
+        nameof(AssetMergeBehavior),
+        nameof(AssetMergeSource),
+        nameof(RelatedAsset),
+        nameof(AssetTraitName),
+        nameof(AssetTraitValue),
+        nameof(Fingerprint),
+        nameof(Integrity),
+        nameof(CopyToOutputDirectory),
+        nameof(CopyToPublishDirectory),
+        nameof(OriginalItemSpec),
+        nameof(FileLength),
+        nameof(LastWriteTime)
+    ];
+
+    ICollection ITaskItem.MetadataNames
+    {
+        get
+        {
+            if (_additionalCustomMetadata == null)
+            {
+                return _defaultPropertyNames;
+            }
+
+            var result = new List<string>(_defaultPropertyNames.Length + _additionalCustomMetadata.Count);
+            result.AddRange(_defaultPropertyNames);
+
+            foreach (var kvp in _additionalCustomMetadata)
+            {
+                result.Add(kvp.Key);
+            }
+
+            return result;
+        }
+    }
+
+    int ITaskItem.MetadataCount => _defaultPropertyNames.Length + (_additionalCustomMetadata?.Count ?? 0);
+
+    string ITaskItem2.GetMetadataValueEscaped(string metadataName)
+    {
+        return metadataName switch
+        {
+            // These two are special and aren't "Real metadata"
+            "FullPath" => Identity ?? "",
+            nameof(Identity) => Identity ?? "",
+            // These are common metadata
+            nameof(SourceId) => SourceId ?? "",
+            nameof(SourceType) => SourceType ?? "",
+            nameof(ContentRoot) => ContentRoot ?? "",
+            nameof(BasePath) => BasePath ?? "",
+            nameof(RelativePath) => RelativePath ?? "",
+            nameof(AssetKind) => AssetKind ?? "",
+            nameof(AssetMode) => AssetMode ?? "",
+            nameof(AssetRole) => AssetRole ?? "",
+            nameof(AssetMergeBehavior) => AssetMergeBehavior ?? "",
+            nameof(AssetMergeSource) => AssetMergeSource ?? "",
+            nameof(RelatedAsset) => RelatedAsset ?? "",
+            nameof(AssetTraitName) => AssetTraitName ?? "",
+            nameof(AssetTraitValue) => AssetTraitValue ?? "",
+            nameof(Fingerprint) => Fingerprint ?? "",
+            nameof(Integrity) => Integrity ?? "",
+            nameof(CopyToOutputDirectory) => CopyToOutputDirectory ?? "",
+            nameof(CopyToPublishDirectory) => CopyToPublishDirectory ?? "",
+            nameof(OriginalItemSpec) => OriginalItemSpec ?? "",
+            nameof(FileLength) => GetFileLengthAsString() ?? "",
+            nameof(LastWriteTime) => GetLastWriteTimeAsString() ?? "",
+            _ => _additionalCustomMetadata?.TryGetValue(metadataName, out var value) == true ? (value ?? "") : "",
+        };
+    }
+
+    private string GetFileLengthAsString() =>
+        FileLength == -1 ? (FileLengthString ?? "") : FileLength.ToString(CultureInfo.InvariantCulture);
+
+    private string GetLastWriteTimeAsString() =>
+        LastWriteTime == DateTimeOffset.MinValue ? (LastWriteTimeString ?? "") : LastWriteTime.ToString(DateTimeAssetFormat, CultureInfo.InvariantCulture);
+
+    void ITaskItem2.SetMetadataValueLiteral(string metadataName, string metadataValue)
+    {
+        metadataValue ??= "";
+        switch (metadataName)
+        {
+            case nameof(SourceId):
+                SourceId = metadataValue;
+                break;
+            case nameof(SourceType):
+                SourceType = metadataValue;
+                break;
+            case nameof(ContentRoot):
+                ContentRoot = metadataValue;
+                break;
+            case nameof(BasePath):
+                BasePath = metadataValue;
+                break;
+            case nameof(RelativePath):
+                RelativePath = metadataValue;
+                break;
+            case nameof(AssetKind):
+                AssetKind = metadataValue;
+                break;
+            case nameof(AssetMode):
+                AssetMode = metadataValue;
+                break;
+            case nameof(AssetRole):
+                AssetRole = metadataValue;
+                break;
+            case nameof(AssetMergeBehavior):
+                AssetMergeBehavior = metadataValue;
+                break;
+            case nameof(AssetMergeSource):
+                AssetMergeSource = metadataValue;
+                break;
+            case nameof(RelatedAsset):
+                RelatedAsset = metadataValue;
+                break;
+            case nameof(AssetTraitName):
+                AssetTraitName = metadataValue;
+                break;
+            case nameof(AssetTraitValue):
+                AssetTraitValue = metadataValue;
+                break;
+            case nameof(Fingerprint):
+                Fingerprint = metadataValue;
+                break;
+            case nameof(Integrity):
+                Integrity = metadataValue;
+                break;
+            case nameof(CopyToOutputDirectory):
+                CopyToOutputDirectory = metadataValue;
+                break;
+            case nameof(CopyToPublishDirectory):
+                CopyToPublishDirectory = metadataValue;
+                break;
+            case nameof(OriginalItemSpec):
+                OriginalItemSpec = metadataValue;
+                break;
+            case nameof(FileLength):
+                _fileLengthString = metadataValue;
+                _fileLength = -1;
+                break;
+            case nameof(LastWriteTime):
+                _lastWriteTimeString = metadataValue;
+                _lastWriteTime = DateTimeOffset.MinValue;
+                break;
+            default:
+                _additionalCustomMetadata ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+                _additionalCustomMetadata[metadataName] = metadataValue;
+                _modified = true;
+                break;
+        }
+    }
+
+    IDictionary ITaskItem2.CloneCustomMetadataEscaped()
+    {
+        var result = new Dictionary<string, string>(((ITaskItem)this).MetadataCount)
+        {
+            { nameof(SourceId), SourceId ?? "" },
+            { nameof(SourceType), SourceType  ?? "" },
+            { nameof(ContentRoot), ContentRoot  ?? "" },
+            { nameof(BasePath), BasePath  ?? "" },
+            { nameof(RelativePath), RelativePath  ?? "" },
+            { nameof(AssetKind), AssetKind  ?? "" },
+            { nameof(AssetMode), AssetMode  ?? "" },
+            { nameof(AssetRole), AssetRole  ?? "" },
+            { nameof(AssetMergeBehavior), AssetMergeBehavior  ?? "" },
+            { nameof(AssetMergeSource), AssetMergeSource  ?? "" },
+            { nameof(RelatedAsset), RelatedAsset  ?? "" },
+            { nameof(AssetTraitName), AssetTraitName  ?? "" },
+            { nameof(AssetTraitValue), AssetTraitValue  ?? "" },
+            { nameof(Fingerprint), Fingerprint  ?? "" },
+            { nameof(Integrity), Integrity  ?? "" },
+            { nameof(CopyToOutputDirectory), CopyToOutputDirectory  ?? "" },
+            { nameof(CopyToPublishDirectory), CopyToPublishDirectory  ?? "" },
+            { nameof(OriginalItemSpec), OriginalItemSpec  ?? "" },
+            { nameof(FileLength), GetFileLengthAsString() ?? "" },
+            { nameof(LastWriteTime), GetLastWriteTimeAsString() ?? "" }
+        };
+        if (_additionalCustomMetadata != null)
+        {
+            foreach (var kvp in _additionalCustomMetadata)
+            {
+                result[kvp.Key] = kvp.Value;
+            }
+        }
+
+        return result;
+    }
+
+    string ITaskItem.GetMetadata(string metadataName) => ((ITaskItem2)this).GetMetadataValueEscaped(metadataName);
+    void ITaskItem.SetMetadata(string metadataName, string metadataValue) => ((ITaskItem2)this).SetMetadataValueLiteral(metadataName, metadataValue);
+
+    void ITaskItem.RemoveMetadata(string metadataName) => _additionalCustomMetadata?.Remove(metadataName);
+
+    void ITaskItem.CopyMetadataTo(ITaskItem destinationItem)
+    {
+        destinationItem.SetMetadata(nameof(SourceId), SourceId ?? "");
+        destinationItem.SetMetadata(nameof(SourceType), SourceType ?? "");
+        destinationItem.SetMetadata(nameof(ContentRoot), ContentRoot ?? "");
+        destinationItem.SetMetadata(nameof(BasePath), BasePath ?? "");
+        destinationItem.SetMetadata(nameof(RelativePath), RelativePath ?? "");
+        destinationItem.SetMetadata(nameof(AssetKind), AssetKind ?? "");
+        destinationItem.SetMetadata(nameof(AssetMode), AssetMode ?? "");
+        destinationItem.SetMetadata(nameof(AssetRole), AssetRole ?? "");
+        destinationItem.SetMetadata(nameof(AssetMergeBehavior), AssetMergeBehavior ?? "");
+        destinationItem.SetMetadata(nameof(AssetMergeSource), AssetMergeSource ?? "");
+        destinationItem.SetMetadata(nameof(RelatedAsset), RelatedAsset ?? "");
+        destinationItem.SetMetadata(nameof(AssetTraitName), AssetTraitName ?? "");
+        destinationItem.SetMetadata(nameof(AssetTraitValue), AssetTraitValue ?? "");
+        destinationItem.SetMetadata(nameof(Fingerprint), Fingerprint ?? "");
+        destinationItem.SetMetadata(nameof(Integrity), Integrity ?? "");
+        destinationItem.SetMetadata(nameof(CopyToOutputDirectory), CopyToOutputDirectory ?? "");
+        destinationItem.SetMetadata(nameof(CopyToPublishDirectory), CopyToPublishDirectory ?? "");
+        destinationItem.SetMetadata(nameof(OriginalItemSpec), OriginalItemSpec ?? "");
+        destinationItem.SetMetadata(nameof(FileLength), GetFileLengthAsString() ?? "");
+        destinationItem.SetMetadata(nameof(LastWriteTime), GetLastWriteTimeAsString() ?? "");
+        if (_additionalCustomMetadata != null)
+        {
+            foreach (var kvp in _additionalCustomMetadata)
+            {
+                destinationItem.SetMetadata(kvp.Key, kvp.Value ?? "");
+            }
+        }
+    }
+
+    IDictionary ITaskItem.CloneCustomMetadata() => ((ITaskItem2)this).CloneCustomMetadataEscaped();
+
+    #endregion
+
     [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
     internal sealed class StaticWebAssetResolvedRoute(string pathLabel, string path, Dictionary<string, string> tokens)
     {
diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs
index 93fe5a55ca34..ea8289a3fe95 100644
--- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs
+++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs
@@ -3,30 +3,141 @@
 
 #nullable disable
 
+using System.Collections;
 using System.Collections.Concurrent;
 using System.Diagnostics;
 using Microsoft.Build.Framework;
-using Microsoft.Build.Utilities;
 
 namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
 [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
-public class StaticWebAssetEndpoint : IEquatable<StaticWebAssetEndpoint>, IComparable<StaticWebAssetEndpoint>
+public class StaticWebAssetEndpoint : IEquatable<StaticWebAssetEndpoint>, IComparable<StaticWebAssetEndpoint>, ITaskItem2
 {
+    private ITaskItem _originalItem;
+    private StaticWebAssetEndpointProperty[] _endpointProperties;
+    private StaticWebAssetEndpointResponseHeader[] _responseHeaders;
+    private StaticWebAssetEndpointSelector[] _selectors;
+    private string _assetFile;
+    private string _route;
+    private bool _modified;
+    private string _selectorsString;
+    private bool _selectorsModified;
+    private string _responseHeadersString;
+    private bool _responseHeadersModified;
+    private string _endpointPropertiesString;
+    private bool _endpointPropertiesModified;
+    private Dictionary<string, string> _additionalCustomMetadata;
+
     // Route as it should be registered in the routing table.
-    public string Route { get; set; }
+    public string Route
+    {
+        get
+        {
+            _route ??= _originalItem?.ItemSpec;
+            return _route;
+        }
+
+        set
+        {
+            _route = value;
+            _modified = true;
+        }
+    }
 
     // Path to the file system as provided by static web assets (BasePath + RelativePath).
-    public string AssetFile { get; set; }
+    public string AssetFile
+    {
+        get
+        {
+            _assetFile ??= _originalItem?.GetMetadata(nameof(AssetFile));
+            return _assetFile;
+        }
+
+        set
+        {
+            _assetFile = value;
+            _modified = true;
+        }
+    }
+
+    private string SelectorsString
+    {
+        get
+        {
+            _selectorsString ??= _originalItem?.GetMetadata(nameof(Selectors));
+            return _selectorsString;
+        }
+    }
 
     // Request values that must be compatible for the file to be selected.
-    public StaticWebAssetEndpointSelector[] Selectors { get; set; } = [];
+    public StaticWebAssetEndpointSelector[] Selectors
+    {
+        get
+        {
+            _selectors ??= StaticWebAssetEndpointSelector.FromMetadataValue(SelectorsString);
+            return _selectors;
+        }
+
+        set
+        {
+            Array.Sort(value);
+            _selectors = value;
+            _selectorsModified = true;
+            _modified = true;
+        }
+    }
+
+    private string ResponseHeadersString
+    {
+        get
+        {
+            _responseHeadersString ??= _originalItem?.GetMetadata(nameof(ResponseHeaders));
+            return _responseHeadersString;
+        }
+    }
 
     // Response headers that must be added to the response.
-    public StaticWebAssetEndpointResponseHeader[] ResponseHeaders { get; set; } = [];
+    public StaticWebAssetEndpointResponseHeader[] ResponseHeaders
+    {
+        get
+        {
+            _responseHeaders ??= StaticWebAssetEndpointResponseHeader.FromMetadataValue(ResponseHeadersString);
+            return _responseHeaders;
+        }
+        set
+        {
+            Array.Sort(value);
+            _responseHeaders = value;
+            _responseHeadersModified = true;
+            _modified = true;
+        }
+    }
+
+    private string EndpointPropertiesString
+    {
+        get
+        {
+            _endpointPropertiesString ??= _originalItem?.GetMetadata(nameof(EndpointProperties));
+            return _endpointPropertiesString;
+        }
+    }
 
     // Properties associated with the endpoint.
-    public StaticWebAssetEndpointProperty[] EndpointProperties { get; set; } = [];
+    public StaticWebAssetEndpointProperty[] EndpointProperties
+    {
+        get
+        {
+            _endpointProperties ??= StaticWebAssetEndpointProperty.FromMetadataValue(EndpointPropertiesString);
+            return _endpointProperties;
+        }
+        set
+        {
+            Array.Sort(value);
+            _endpointProperties = value;
+            _endpointPropertiesModified = true;
+            _modified = true;
+        }
+    }
 
     public static IEqualityComparer<StaticWebAssetEndpoint> RouteAndAssetComparer { get; } = new RouteAndAssetEqualityComparer();
 
@@ -36,9 +147,6 @@ public static StaticWebAssetEndpoint[] FromItemGroup(ITaskItem[] endpoints)
         for (var i = 0; i < endpoints.Length; i++)
         {
             result[i] = FromTaskItem(endpoints[i]);
-            Array.Sort(result[i].ResponseHeaders);
-            Array.Sort(result[i].Selectors);
-            Array.Sort(result[i].EndpointProperties);
         }
 
         Array.Sort(result, (a, b) => (a.Route, b.Route) switch
@@ -60,11 +168,7 @@ public static StaticWebAssetEndpoint FromTaskItem(ITaskItem item)
     {
         var result = new StaticWebAssetEndpoint()
         {
-            Route = item.ItemSpec,
-            AssetFile = item.GetMetadata(nameof(AssetFile)),
-            Selectors = StaticWebAssetEndpointSelector.FromMetadataValue(item.GetMetadata(nameof(Selectors))),
-            ResponseHeaders = StaticWebAssetEndpointResponseHeader.FromMetadataValue(item.GetMetadata(nameof(ResponseHeaders))),
-            EndpointProperties = StaticWebAssetEndpointProperty.FromMetadataValue(item.GetMetadata(nameof(EndpointProperties)))
+            _originalItem = item,
         };
 
         return result;
@@ -86,14 +190,15 @@ public static ITaskItem[] ToTaskItems(IList<StaticWebAssetEndpoint> endpoints)
         return endpointItems;
     }
 
-    public TaskItem ToTaskItem()
+    public ITaskItem ToTaskItem()
     {
-        var item = new TaskItem(Route);
-        item.SetMetadata(nameof(AssetFile), AssetFile);
-        item.SetMetadata(nameof(Selectors), StaticWebAssetEndpointSelector.ToMetadataValue(Selectors));
-        item.SetMetadata(nameof(ResponseHeaders), StaticWebAssetEndpointResponseHeader.ToMetadataValue(ResponseHeaders));
-        item.SetMetadata(nameof(EndpointProperties), StaticWebAssetEndpointProperty.ToMetadataValue(EndpointProperties));
-        return item;
+        if (!_modified && _originalItem != null)
+        {
+            return _originalItem;
+        }
+
+        // If we're implementing ITaskItem2, we can just return this instance
+        return this;
     }
 
     public override bool Equals(object obj) => Equals(obj as StaticWebAssetEndpoint);
@@ -282,4 +387,129 @@ public int GetHashCode(StaticWebAssetEndpoint obj)
 #endif
         }
     }
+
+    #region ITaskItem2 implementation
+
+    string ITaskItem2.EvaluatedIncludeEscaped { get => Route; set => Route = value; }
+    string ITaskItem.ItemSpec { get => Route; set => Route = value; }
+
+    private static readonly string[] _defaultPropertyNames = [
+        nameof(AssetFile),
+        nameof(Selectors),
+        nameof(ResponseHeaders),
+        nameof(EndpointProperties)
+    ];
+
+    ICollection ITaskItem.MetadataNames
+    {
+        get
+        {
+            if (_additionalCustomMetadata == null)
+            {
+                return _defaultPropertyNames;
+            }
+
+            var result = new List<string>(_defaultPropertyNames.Length + _additionalCustomMetadata.Count);
+            result.AddRange(_defaultPropertyNames);
+
+            foreach (var kvp in _additionalCustomMetadata)
+            {
+                result.Add(kvp.Key);
+            }
+
+            return result;
+        }
+    }
+
+    int ITaskItem.MetadataCount => _defaultPropertyNames.Length + (_additionalCustomMetadata?.Count ?? 0);
+
+    string ITaskItem2.GetMetadataValueEscaped(string metadataName)
+    {
+        return metadataName switch
+        {
+            nameof(AssetFile) => AssetFile ?? "",
+            nameof(Selectors) => !_selectorsModified ? SelectorsString ?? "" : StaticWebAssetEndpointSelector.ToMetadataValue(Selectors),
+            nameof(ResponseHeaders) => !_responseHeadersModified ? ResponseHeadersString ?? "" : StaticWebAssetEndpointResponseHeader.ToMetadataValue(ResponseHeaders),
+            nameof(EndpointProperties) => !_endpointPropertiesModified ? EndpointPropertiesString ?? "" : StaticWebAssetEndpointProperty.ToMetadataValue(EndpointProperties),
+            _ => _additionalCustomMetadata?.TryGetValue(metadataName, out var value) == true ? (value ?? "") : "",
+        };
+    }
+
+    void ITaskItem2.SetMetadataValueLiteral(string metadataName, string metadataValue)
+    {
+        metadataValue ??= "";
+        switch (metadataName)
+        {
+            case nameof(AssetFile):
+                AssetFile = metadataValue;
+                break;
+            case nameof(Selectors):
+                _selectorsString = metadataValue;
+                _selectors = null;
+                _selectorsModified = false;
+                break;
+            case nameof(ResponseHeaders):
+                _responseHeadersString = metadataValue;
+                _responseHeaders = null;
+                _responseHeadersModified = false;
+                break;
+            case nameof(EndpointProperties):
+                _endpointPropertiesString = metadataValue;
+                _endpointProperties = null;
+                _endpointPropertiesModified = false;
+                break;
+            default:
+                _additionalCustomMetadata ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+                _additionalCustomMetadata[metadataName] = metadataValue;
+                break;
+        }
+        _modified = true;
+    }
+
+    IDictionary ITaskItem2.CloneCustomMetadataEscaped()
+    {
+        var result = new Dictionary<string, string>(((ITaskItem)this).MetadataCount)
+        {
+            { nameof(AssetFile), AssetFile ?? "" },
+            { nameof(Selectors), !_selectorsModified ? SelectorsString ?? "" : StaticWebAssetEndpointSelector.ToMetadataValue(Selectors) },
+            { nameof(ResponseHeaders), !_responseHeadersModified ? ResponseHeadersString ?? "" : StaticWebAssetEndpointResponseHeader.ToMetadataValue(ResponseHeaders) },
+            { nameof(EndpointProperties), !_endpointPropertiesModified ? EndpointPropertiesString ?? "" : StaticWebAssetEndpointProperty.ToMetadataValue(EndpointProperties) }
+        };
+
+        if (_additionalCustomMetadata != null)
+        {
+            foreach (var kvp in _additionalCustomMetadata)
+            {
+                result[kvp.Key] = kvp.Value;
+            }
+        }
+
+        return result;
+    }
+
+    string ITaskItem.GetMetadata(string metadataName) => ((ITaskItem2)this).GetMetadataValueEscaped(metadataName);
+
+    void ITaskItem.SetMetadata(string metadataName, string metadataValue) => ((ITaskItem2)this).SetMetadataValueLiteral(metadataName, metadataValue);
+
+    void ITaskItem.RemoveMetadata(string metadataName) => _additionalCustomMetadata?.Remove(metadataName);
+
+    void ITaskItem.CopyMetadataTo(ITaskItem destinationItem)
+    {
+        destinationItem.SetMetadata(nameof(AssetFile), AssetFile ?? "");
+        destinationItem.SetMetadata(nameof(Selectors), !_selectorsModified ? SelectorsString ?? "" : StaticWebAssetEndpointSelector.ToMetadataValue(Selectors));
+        destinationItem.SetMetadata(nameof(ResponseHeaders), !_responseHeadersModified ? ResponseHeadersString ?? "" : StaticWebAssetEndpointResponseHeader.ToMetadataValue(ResponseHeaders));
+        destinationItem.SetMetadata(nameof(EndpointProperties), !_endpointPropertiesModified ? EndpointPropertiesString ?? "" : StaticWebAssetEndpointProperty.ToMetadataValue(EndpointProperties));
+
+        if (_additionalCustomMetadata != null)
+        {
+            foreach (var kvp in _additionalCustomMetadata)
+            {
+                destinationItem.SetMetadata(kvp.Key, kvp.Value ?? "");
+            }
+        }
+    }
+
+    IDictionary ITaskItem.CloneCustomMetadata() => ((ITaskItem2)this).CloneCustomMetadataEscaped();
+
+    #endregion
 }
diff --git a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.Cache.cs b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.Cache.cs
index b9cd0e25b386..f28987f86c22 100644
--- a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.Cache.cs
+++ b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.Cache.cs
@@ -41,7 +41,7 @@ private DefineStaticWebAssetsCache GetOrCreateAssetsCache()
         var patternMetadata = new[] { nameof(FingerprintPattern.Pattern), nameof(FingerprintPattern.Expression) };
         var fingerprintPatternsHash = HashingUtils.ComputeHash(memoryStream, FingerprintPatterns ?? [], patternMetadata);
 
-        var propertyOverridesHash = HashingUtils.ComputeHash(memoryStream, PropertyOverrides, nameof(ITaskItem.GetMetadata));
+        var propertyOverridesHash = HashingUtils.ComputeHash(memoryStream, PropertyOverrides ?? []);
 
 #if NET9_0_OR_GREATER
         Span<string> candidateAssetMetadata = [
diff --git a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs
index c31db63a574e..494e3c3263b9 100644
--- a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs
+++ b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs
@@ -25,7 +25,7 @@ public partial class DefineStaticWebAssets : Task
     [Required]
     public ITaskItem[] CandidateAssets { get; set; }
 
-    public ITaskItem[] PropertyOverrides { get; set; }
+    public string[] PropertyOverrides { get; set; }
 
     public string SourceId { get; set; }
 
@@ -73,8 +73,11 @@ public partial class DefineStaticWebAssets : Task
 
     public Func<string, string, (FileInfo file, long fileLength, DateTimeOffset lastWriteTimeUtc)> TestResolveFileDetails { get; set; }
 
+    private HashSet<string> _overrides;
+
     public override bool Execute()
     {
+        _overrides = new HashSet<string>(PropertyOverrides ?? [], StringComparer.OrdinalIgnoreCase);
         var assetsCache = GetOrCreateAssetsCache();
 
         if (assetsCache.IsUpToDate())
@@ -96,7 +99,7 @@ public override bool Execute()
                 new StaticWebAssetGlobMatcherBuilder().AddIncludePatterns(RelativePathFilter).Build() :
                 null;
 
-            var assetsByRelativePath = new Dictionary<string, List<ITaskItem>>();
+            var assetsByRelativePath = new Dictionary<string, (ITaskItem First, ITaskItem Second)>(CandidateAssets.Length);
             var fingerprintPatternMatcher = new FingerprintPatternMatcher(Log, FingerprintCandidates ? (FingerprintPatterns ?? []) : []);
             var matchContext = StaticWebAssetGlobMatcher.CreateMatchContext();
             foreach (var kvp in assetsCache.OutOfDateInputs())
@@ -356,7 +359,7 @@ public override bool Execute()
 
     private string ComputePropertyValue(ITaskItem element, string metadataName, string propertyValue, bool isRequired = true)
     {
-        if (PropertyOverrides != null && PropertyOverrides.Any(a => string.Equals(a.ItemSpec, metadataName, StringComparison.OrdinalIgnoreCase)))
+        if (_overrides.Contains(metadataName))
         {
             return propertyValue;
         }
@@ -428,7 +431,9 @@ private string GetCandidateMatchPath(ITaskItem candidate)
         }
     }
 
-    private void UpdateAssetKindIfNecessary(Dictionary<string, List<ITaskItem>> assetsByRelativePath, string candidateRelativePath, ITaskItem asset)
+    private void UpdateAssetKindIfNecessary(
+        Dictionary<string, (ITaskItem First, ITaskItem Second)> assetsByRelativePath,
+        string candidateRelativePath, ITaskItem asset)
     {
         // We want to support content items in the form of
         // <Content Include="service-worker.development.js CopyToPublishDirectory="Never" TargetPath="wwwroot\service-worker.js" />
@@ -439,14 +444,13 @@ private void UpdateAssetKindIfNecessary(Dictionary<string, List<ITaskItem>> asse
         // As a result, assets by default have an asset kind 'All' when there is only one asset for the target path and 'Build' or 'Publish' when there are two of them.
         if (!assetsByRelativePath.TryGetValue(candidateRelativePath, out var existing))
         {
-            assetsByRelativePath.Add(candidateRelativePath, [asset]);
+            assetsByRelativePath.Add(candidateRelativePath, (asset, null));
         }
         else
         {
-            if (existing.Count == 2)
+            var (first, second) = existing;
+            if (first != null && second != null)
             {
-                var first = existing[0];
-                var second = existing[1];
                 var errorMessage = "More than two assets are targeting the same path: " + Environment.NewLine +
                     "'{0}' with kind '{1}'" + Environment.NewLine +
                     "'{2}' with kind '{3}'" + Environment.NewLine +
@@ -462,9 +466,9 @@ private void UpdateAssetKindIfNecessary(Dictionary<string, List<ITaskItem>> asse
 
                 return;
             }
-            else if (existing.Count == 1)
+            else if (first != null && second == null)
             {
-                var existingAsset = existing[0];
+                var existingAsset = first;
                 switch ((asset.GetMetadata(nameof(StaticWebAsset.CopyToPublishDirectory)), existingAsset.GetMetadata(nameof(StaticWebAsset.CopyToPublishDirectory))))
                 {
                     case (StaticWebAsset.AssetCopyOptions.Never, StaticWebAsset.AssetCopyOptions.Never):
@@ -484,7 +488,7 @@ private void UpdateAssetKindIfNecessary(Dictionary<string, List<ITaskItem>> asse
                         break;
 
                     case (StaticWebAsset.AssetCopyOptions.Never, not StaticWebAsset.AssetCopyOptions.Never):
-                        existing.Add(asset);
+                        existing.Second = asset;
                         asset.SetMetadata(nameof(StaticWebAsset.AssetKind), StaticWebAsset.AssetKinds.Build);
                         existingAsset.SetMetadata(nameof(StaticWebAsset.AssetKind), StaticWebAsset.AssetKinds.Publish);
                         Log.LogMessage(MessageImportance.Low,
@@ -504,7 +508,7 @@ private void UpdateAssetKindIfNecessary(Dictionary<string, List<ITaskItem>> asse
                         break;
 
                     case (not StaticWebAsset.AssetCopyOptions.Never, StaticWebAsset.AssetCopyOptions.Never):
-                        existing.Add(asset);
+                        existing.Second = asset;
                         asset.SetMetadata(nameof(StaticWebAsset.AssetKind), StaticWebAsset.AssetKinds.Publish);
                         existingAsset.SetMetadata(nameof(StaticWebAsset.AssetKind), StaticWebAsset.AssetKinds.Build);
                         Log.LogMessage(MessageImportance.Low,
diff --git a/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs
index 7976724ad6b4..5ddbd12f8530 100644
--- a/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs
+++ b/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs
@@ -32,7 +32,7 @@ public class FilterStaticWebAssetEndpoints : Task
     public override bool Execute()
     {
         var filterCriteria = (Filters ?? []).Select(FilterCriteria.FromTaskItem).ToArray();
-        var assetFiles = (Assets ?? []).ToDictionary(a => a.ItemSpec, StaticWebAsset.FromTaskItem);
+        var assetFiles = Assets != null ? StaticWebAsset.ToAssetDictionary(Assets) : [];
         var endpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints ?? []);
         var endpointFoundMatchingAsset = new Dictionary<string, StaticWebAsset>();
 
diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs
index 17a80c495dff..61706aebbc4f 100644
--- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs
+++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs
@@ -37,7 +37,8 @@ public override bool Execute()
         try
         {
             // Get the list of the asset that need to be part of the manifest (this is similar to GenerateStaticWebAssetsDevelopmentManifest)
-            var manifestAssets = ComputeManifestAssets(Assets.Select(StaticWebAsset.FromTaskItem), ManifestType)
+            var assets = StaticWebAsset.FromTaskItemGroup(Assets);
+            var manifestAssets = ComputeManifestAssets(assets, ManifestType)
                 .ToDictionary(a => a.ResolvedAsset.Identity, a => a, OSPath.PathComparer);
 
             // Filter out the endpoints to those that point to the assets that are part of the manifest
diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs
index 87682fe487cc..071f42ac285d 100644
--- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs
+++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs
@@ -25,7 +25,7 @@ public class GenerateStaticWebAssetEndpointsPropsFile : Task
     public override bool Execute()
     {
         var endpoints = StaticWebAssetEndpoint.FromItemGroup(StaticWebAssetEndpoints);
-        var assets = StaticWebAssets.Select(StaticWebAsset.FromTaskItem).ToDictionary(a => a.Identity, a => a);
+        var assets = StaticWebAsset.ToAssetDictionary(StaticWebAssets);
         if (!ValidateArguments(endpoints, assets))
         {
             return false;
diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs
index a70eaf4acd68..00d4f39c69e1 100644
--- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs
+++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs
@@ -49,7 +49,7 @@ public override bool Execute()
             }
 
             var manifest = ComputeDevelopmentManifest(
-                Assets.Select(StaticWebAsset.FromTaskItem),
+                StaticWebAsset.FromTaskItemGroup(Assets),
                 DiscoveryPatterns.Select(StaticWebAssetsDiscoveryPattern.FromTaskItem));
 
             PersistManifest(manifest);
diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsManifest.cs
index 8acdee2f6f73..1bf04ed48714 100644
--- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsManifest.cs
+++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsManifest.cs
@@ -44,19 +44,22 @@ public override bool Execute()
     {
         try
         {
-            var assets = Assets.OrderBy(a => a.GetMetadata("FullPath")).Select(StaticWebAsset.FromTaskItem).ToArray();
+            var assets = StaticWebAsset.FromTaskItemGroup(Assets, validate: true);
+            Array.Sort(assets, (l, r) => string.CompareOrdinal(l.Identity, r.Identity));
 
-            var endpoints = FilterPublishEndpointsIfNeeded(assets)
-                .OrderBy(a => a.Route)
-                .ThenBy(a => a.AssetFile)
-                .ToArray();
+            var endpoints = FilterPublishEndpointsIfNeeded(assets);
+            Array.Sort(endpoints, (l, r) => string.CompareOrdinal(l.Route, r.Route) switch
+            {
+                0 => string.CompareOrdinal(l.AssetFile, r.AssetFile),
+                int result => result,
+            });
 
             Log.LogMessage(MessageImportance.Low, "Generating manifest for '{0}' assets and '{1}' endpoints", assets.Length, endpoints.Length);
 
-            var assetsByTargetPath = assets.GroupBy(a => a.ComputeTargetPath("", '/'), StringComparer.OrdinalIgnoreCase);
+            var assetsByTargetPath = GroupAssetsByTargetPath(assets);
             foreach (var group in assetsByTargetPath)
             {
-                if (!StaticWebAsset.ValidateAssetGroup(group.Key, [.. group], out var reason))
+                if (!StaticWebAsset.ValidateAssetGroup(group.Key, group.Value, out var reason))
                 {
                     Log.LogError(reason);
                     return false;
@@ -90,7 +93,7 @@ public override bool Execute()
         return !Log.HasLoggedErrors;
     }
 
-    private IEnumerable<StaticWebAssetEndpoint> FilterPublishEndpointsIfNeeded(IEnumerable<StaticWebAsset> assets)
+    private StaticWebAssetEndpoint[] FilterPublishEndpointsIfNeeded(StaticWebAsset[] assets)
     {
         // Only include endpoints for assets that are going to be available in production. We do the filtering
         // inside the manifest because its cumbersome to do it in MSBuild directly.
@@ -112,10 +115,10 @@ private IEnumerable<StaticWebAssetEndpoint> FilterPublishEndpointsIfNeeded(IEnum
                 }
             }
 
-            return filteredEndpoints;
+            return [.. filteredEndpoints];
         }
 
-        return Endpoints.Select(StaticWebAssetEndpoint.FromTaskItem);
+        return StaticWebAssetEndpoint.FromItemGroup(Endpoints);
     }
 
     private void PersistManifest(StaticWebAssetsManifest manifest)
@@ -148,4 +151,36 @@ private void PersistManifest(StaticWebAssetsManifest manifest)
             Log.LogMessage(MessageImportance.Low, $"Skipping manifest updated because manifest version '{manifest.Hash}' has not changed.");
         }
     }
+
+    private static Dictionary<string, (StaticWebAsset First, StaticWebAsset Second, List<StaticWebAsset> Other)> GroupAssetsByTargetPath(StaticWebAsset[] assets)
+    {
+        var result = new Dictionary<string, (StaticWebAsset First, StaticWebAsset Second, List<StaticWebAsset> Other)>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (var asset in assets)
+        {
+            var targetPath = asset.ComputeTargetPath("", '/');
+
+            if (result.TryGetValue(targetPath, out var existing))
+            {
+                if (existing.Second == null)
+                {
+                    // We have first but not second
+                    result[targetPath] = (existing.First, asset, null);
+                }
+                else
+                {
+                    // We already have first and second, add to rest
+                    existing.Other ??= [];
+                    existing.Other.Add(asset);
+                }
+            }
+            else
+            {
+                // First asset with this path
+                result.Add(targetPath, (asset, null, null));
+            }
+        }
+
+        return result;
+    }
 }
diff --git a/src/StaticWebAssetsSdk/Tasks/MergeConfigurationProperties.cs b/src/StaticWebAssetsSdk/Tasks/MergeConfigurationProperties.cs
index cd8a1aea0fbd..97820eae8836 100644
--- a/src/StaticWebAssetsSdk/Tasks/MergeConfigurationProperties.cs
+++ b/src/StaticWebAssetsSdk/Tasks/MergeConfigurationProperties.cs
@@ -25,7 +25,7 @@ public override bool Execute()
     {
         try
         {
-            ProjectConfigurations = new TaskItem[CandidateConfigurations.Length];
+            ProjectConfigurations = new ITaskItem[CandidateConfigurations.Length];
 
             for (var i = 0; i < CandidateConfigurations.Length; i++)
             {
diff --git a/src/StaticWebAssetsSdk/Tasks/MergeStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/MergeStaticWebAssets.cs
index 0d16ba60c071..dbd49ba5a3e2 100644
--- a/src/StaticWebAssetsSdk/Tasks/MergeStaticWebAssets.cs
+++ b/src/StaticWebAssetsSdk/Tasks/MergeStaticWebAssets.cs
@@ -27,7 +27,8 @@ public class MergeStaticWebAssets : Task
     public override bool Execute()
     {
 
-        var assets = CandidateAssets.OrderBy(a => a.GetMetadata("FullPath")).Select(StaticWebAsset.FromTaskItem);
+        var assets = StaticWebAsset.FromTaskItemGroup(CandidateAssets);
+        Array.Sort(assets, (a, b) => string.CompareOrdinal(a.Identity, b.Identity));
 
         var assetsByTargetPath = assets
             .GroupBy(a => a.ComputeTargetPath("", '/'), StringComparer.OrdinalIgnoreCase)
diff --git a/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs b/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs
index c62afb9e0d59..ab4969e9a8f9 100644
--- a/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs
@@ -26,7 +26,7 @@ public class ResolveFingerprintedStaticWebAssetEndpointsForAssets : Task
     public override bool Execute()
     {
         var candidateEndpoints = StaticWebAssetEndpoint.FromItemGroup(CandidateEndpoints);
-        var candidateAssets = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray();
+        var candidateAssets = StaticWebAsset.FromTaskItemGroup(CandidateAssets);
         var resolvedEndpoints = new List<StaticWebAssetEndpoint>();
 
         var endpointsByAsset = candidateEndpoints.GroupBy(e => e.AssetFile, OSPath.PathComparer)
diff --git a/src/StaticWebAssetsSdk/Tasks/ResolveStaticWebAssetEndpointRoutes.cs b/src/StaticWebAssetsSdk/Tasks/ResolveStaticWebAssetEndpointRoutes.cs
index 188a586dc5fe..b3138921d14e 100644
--- a/src/StaticWebAssetsSdk/Tasks/ResolveStaticWebAssetEndpointRoutes.cs
+++ b/src/StaticWebAssetsSdk/Tasks/ResolveStaticWebAssetEndpointRoutes.cs
@@ -18,7 +18,7 @@ public class ResolveStaticWebAssetEndpointRoutes : Task
     public override bool Execute()
     {
         var endpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints);
-        var assets = Assets.Select(StaticWebAsset.FromTaskItem).ToDictionary(a => a.Identity, a => a);
+        var assets = StaticWebAsset.ToAssetDictionary(Assets);
 
         foreach (var endpoint in endpoints)
         {
diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs
index 9eea5aa2c9d8..c4f588ccb014 100644
--- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs
+++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs
@@ -103,7 +103,7 @@ private static ITaskItem CreateCandidate(
         return result.ToTaskItem();
     }
 
-    private static TaskItem CreateCandidateEndpoint(string route, string assetFile)
+    private static ITaskItem CreateCandidateEndpoint(string route, string assetFile)
     {
         return new StaticWebAssetEndpoint
         {
diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs
index ba3466eeedd7..3520d8438c63 100644
--- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs
+++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs
@@ -23,7 +23,7 @@ public void IncludesAssetsFromCurrentProjectAsReferencedAssets()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Build",
                 ProjectMode = "Default"
@@ -49,7 +49,7 @@ public void IncludesPatternsFromCurrentProject()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All") },
                 Patterns = new[] { CreatePatternCandidate("MyPackage\\wwwroot", "base", Directory.GetCurrentDirectory(), "wwwroot\\**", "MyPackage") },
                 AssetKind = "Build",
                 ProjectMode = "Default"
@@ -75,7 +75,7 @@ public void FiltersPatternsFromReferencedProjects()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All") },
                 Patterns = new[] { CreatePatternCandidate("Other\\wwwroot", "base", Directory.GetCurrentDirectory(), "wwwroot\\**", "Other") },
                 AssetKind = "Build",
                 ProjectMode = "Default"
@@ -103,8 +103,8 @@ public void PrefersSpecificKindAssetsOverAllKindAssets()
                 Source = "MyPackage",
                 Assets = new[]
                 {
-                    CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All"),
-                    CreateCandidate("wwwroot\\candidate.other.js", "MyPackage", "Discovered", "candidate.js", "Build", "All")
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All"),
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.other.js"), "MyPackage", "Discovered", "candidate.js", "Build", "All")
                 },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Build",
@@ -134,9 +134,9 @@ public void AllAssetGetsIgnoredWhenBuildAndPublishAssetsAreDefined()
                 Source = "MyPackage",
                 Assets = new[]
                 {
-                    CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All"),
-                    CreateCandidate("wwwroot\\candidate.other.js", "MyPackage", "Discovered", "candidate.js", "Build", "All"),
-                    CreateCandidate("wwwroot\\candidate.publish.js", "MyPackage", "Discovered", "candidate.js", "Publish", "All")
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All"),
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.other.js"), "MyPackage", "Discovered", "candidate.js", "Build", "All"),
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.publish.js"), "MyPackage", "Discovered", "candidate.js", "Publish", "All")
                 },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Build",
@@ -166,7 +166,7 @@ public void FiltersAssetsForOppositeKind(string assetKind, string manifestKind)
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", assetKind, "All") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", assetKind, "All") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = manifestKind,
                 ProjectMode = "Default"
@@ -192,7 +192,7 @@ public void FiltersCurrentProjectOnlyAssetsInDefaultMode()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "CurrentProject") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "CurrentProject") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Default",
                 ProjectMode = "Default"
@@ -218,7 +218,7 @@ public void IncludesReferenceAssetsInDefaultMode()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "Reference") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "Reference") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Default",
                 ProjectMode = "Default"
@@ -244,7 +244,7 @@ public void IncludesCurrentProjectAssetsInRootMode()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "CurrentProject") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "CurrentProject") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Default",
                 ProjectMode = "Root"
@@ -270,7 +270,7 @@ public void FiltersReferenceOnlyAssetsInRootMode()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "Reference") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "Reference") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Default",
                 ProjectMode = "Root"
@@ -296,7 +296,7 @@ public void FiltersAssetsFromOtherProjects()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "Other", "Project", "candidate.js", "All", "All") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "Other", "Project", "candidate.js", "All", "All") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Build",
                 ProjectMode = "Default"
@@ -322,7 +322,7 @@ public void FiltersAssetsFromPackages()
             {
                 BuildEngine = buildEngine.Object,
                 Source = "MyPackage",
-                Assets = new[] { CreateCandidate("wwwroot\\candidate.js", "Other", "Package", "candidate.js", "All", "All") },
+                Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "Other", "Package", "candidate.js", "All", "All") },
                 Patterns = new ITaskItem[] { },
                 AssetKind = "Build",
                 ProjectMode = "Default"
diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeStaticWebAssetsForCurrentProjectTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeStaticWebAssetsForCurrentProjectTest.cs
index 3006dfd1d314..7e9883ec140f 100644
--- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeStaticWebAssetsForCurrentProjectTest.cs
+++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ComputeStaticWebAssetsForCurrentProjectTest.cs
@@ -51,8 +51,8 @@ public void PrefersSpecificKindAssetsOverAllKindAssets()
                 Source = "MyPackage",
                 Assets = new[]
                 {
-                    CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All"),
-                    CreateCandidate("wwwroot\\candidate.other.js", "MyPackage", "Discovered", "candidate.js", "Build", "All")
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All"),
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.other.js"), "MyPackage", "Discovered", "candidate.js", "Build", "All")
                 },
                 AssetKind = "Build",
                 ProjectMode = "Default"
@@ -81,9 +81,9 @@ public void AllAssetGetsIgnoredWhenBuildAndPublishAssetsAreDefined()
                 Source = "MyPackage",
                 Assets = new[]
                 {
-                    CreateCandidate("wwwroot\\candidate.js", "MyPackage", "Discovered", "candidate.js", "All", "All"),
-                    CreateCandidate("wwwroot\\candidate.other.js", "MyPackage", "Discovered", "candidate.js", "Build", "All"),
-                    CreateCandidate("wwwroot\\candidate.publish.js", "MyPackage", "Discovered", "candidate.js", "Publish", "All")
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Discovered", "candidate.js", "All", "All"),
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.other.js"), "MyPackage", "Discovered", "candidate.js", "Build", "All"),
+                    CreateCandidate(Path.Combine("wwwroot", "candidate.publish.js"), "MyPackage", "Discovered", "candidate.js", "Publish", "All")
                 },
                 AssetKind = "Build",
                 ProjectMode = "Default"
diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsPropsFileTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsPropsFileTest.cs
index babb4c8c5c67..db86ce0c9efc 100644
--- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsPropsFileTest.cs
+++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsPropsFileTest.cs
@@ -208,7 +208,7 @@ private static ITaskItem CreateStaticWebAsset(
         return result.ToTaskItem();
     }
 
-    private static TaskItem CreateStaticWebAssetEndpoint(
+    private static ITaskItem CreateStaticWebAssetEndpoint(
         string route,
         string assetFile,
         StaticWebAssetEndpointResponseHeader[] responseHeaders = null,
diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ResolveFingerprintedStaticWebAssetEndpointsForAssetsTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ResolveFingerprintedStaticWebAssetEndpointsForAssetsTest.cs
index 8a0da4bc9bb1..95e0f87a0bc5 100644
--- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ResolveFingerprintedStaticWebAssetEndpointsForAssetsTest.cs
+++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ResolveFingerprintedStaticWebAssetEndpointsForAssetsTest.cs
@@ -36,7 +36,7 @@ public void Standalone_Selects_EndpointMatching_FilePath(string pattern, string
             )
         ];
 
-        var endpoints = CreateEndpoints(candidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray());
+        var endpoints = CreateEndpoints(candidateAssets.Select(a => StaticWebAsset.FromTaskItem(a)).ToArray());
 
         var resolvedEndpoints = new ResolveFingerprintedStaticWebAssetEndpointsForAssets
         {
@@ -79,7 +79,7 @@ public void StandaloneFails_MatchingEndpointNotFound()
             )
         ];
 
-        var endpoints = CreateEndpoints(candidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray());
+        var endpoints = CreateEndpoints(candidateAssets.Select(a => StaticWebAsset.FromTaskItem(a)).ToArray());
         endpoints = endpoints.Where(e => !e.Route.Contains("asdf1234")).ToArray();
 
         var resolvedEndpoints = new ResolveFingerprintedStaticWebAssetEndpointsForAssets
@@ -119,7 +119,7 @@ public void Hosted_AlwaysPrefers_FingerprintedEndpoint(string pattern, string ex
             )
         ];
 
-        var endpoints = CreateEndpoints(candidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray());
+        var endpoints = CreateEndpoints(candidateAssets.Select(a => StaticWebAsset.FromTaskItem(a)).ToArray());
 
         var resolvedEndpoints = new ResolveFingerprintedStaticWebAssetEndpointsForAssets
         {
@@ -162,7 +162,7 @@ public void Hosted_FallsBackToNonFingerprintedEndpoint_WhenFingerprintedVersionN
             )
         ];
 
-        var endpoints = CreateEndpoints(candidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray());
+        var endpoints = CreateEndpoints(candidateAssets.Select(a => StaticWebAsset.FromTaskItem(a)).ToArray());
 
         var resolvedEndpoints = new ResolveFingerprintedStaticWebAssetEndpointsForAssets
         {
@@ -205,7 +205,7 @@ public void Hosted_FailsWhen_DoesnotFindMatchingEndpoint()
             )
         ];
 
-        var endpoints = CreateEndpoints(candidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray());
+        var endpoints = CreateEndpoints(candidateAssets.Select(a => StaticWebAsset.FromTaskItem(a)).ToArray());
         endpoints = endpoints.Where(e => !e.Route.Contains("asdf1234")).ToArray();
         endpoints[0].AssetFile = Path.GetFullPath("other.js");