diff --git a/eng/illink.targets b/eng/illink.targets
index 0f0c3cab3c45d7..5304ecd8d1eb34 100644
--- a/eng/illink.targets
+++ b/eng/illink.targets
@@ -20,8 +20,6 @@
     <ILLinkTrimOutputPath>$(IntermediateOutputPath)</ILLinkTrimOutputPath>
 
     <ILLinkDescriptorsXml Condition="'$(ILLinkDescriptorsXml)' == '' and Exists('$(ILLinkDirectory)ILLink.Descriptors.xml')">$(ILLinkDirectory)ILLink.Descriptors.xml</ILLinkDescriptorsXml>
-    <!-- ILLink.Descriptors.LibraryBuild.xml files are only used during building the library, not an app. They shouldn't be embedded into the assembly. -->
-    <ILLinkDescriptorsLibraryBuildXml Condition="'$(ILLinkDescriptorsLibraryBuildXml)' == '' and Exists('$(ILLinkDirectory)ILLink.Descriptors.LibraryBuild.xml')">$(ILLinkDirectory)ILLink.Descriptors.LibraryBuild.xml</ILLinkDescriptorsLibraryBuildXml>
     <ILLinkDescriptorsXmlIntermediatePath>$(IntermediateOutputPath)ILLink.Descriptors.xml</ILLinkDescriptorsXmlIntermediatePath>
 
     <ILLinkSubstitutionsXmlIntermediatePath>$(IntermediateOutputPath)ILLink.Substitutions.xml</ILLinkSubstitutionsXmlIntermediatePath>
@@ -41,6 +39,9 @@
   </PropertyGroup>
 
    <ItemGroup>
+    <!-- ILLink.Descriptors.LibraryBuild.xml files are only used during building the library, not an app. They shouldn't be embedded into the assembly. -->
+    <ILLinkDescriptorsLibraryBuildXml Include="$(ILLinkDirectory)ILLink.Descriptors.LibraryBuild.xml"
+                                      Condition="Exists('$(ILLinkDirectory)ILLink.Descriptors.LibraryBuild.xml')" />
     <ILLinkSuppressionsLibraryBuildXml Include="$(ILLinkSuppressionsXmlPrefix).LibraryBuild.xml"
                                        Condition="Exists('$(ILLinkSuppressionsXmlPrefix).LibraryBuild.xml')" />
 
@@ -210,7 +211,7 @@
       <ILLinkArgs Condition="'$(ILLinkRewritePDBs)' == 'true' and Exists('$(ILLinkTrimAssemblySymbols)')">$(ILLinkArgs) -b true</ILLinkArgs>
       <ILLinkArgs Condition="'$(ILLinkRewritePDBs)' == 'true' and Exists('$(ILLinkTrimAssemblySymbols)') and '$(DeterministicSourcePaths)' == 'true'">$(ILLinkArgs) --preserve-symbol-paths</ILLinkArgs>
       <!-- pass the non-embedded descriptors xml file on the command line -->
-      <ILLinkArgs Condition="'$(ILLinkDescriptorsLibraryBuildXml)' != ''">$(ILLinkArgs) -x "$(ILLinkDescriptorsLibraryBuildXml)"</ILLinkArgs>
+      <ILLinkArgs Condition="'@(ILLinkDescriptorsLibraryBuildXml)' != ''">$(ILLinkArgs) -x "@(ILLinkDescriptorsLibraryBuildXml->'%(FullPath)', '" -x "')"</ILLinkArgs>
       <ILLinkArgs Condition="'$(ILLinkSubstitutionsLibraryBuildXml)' != ''">$(ILLinkArgs) --substitutions "$(ILLinkSubstitutionsLibraryBuildXml)"</ILLinkArgs>
       <ILLinkArgs Condition="'@(ILLinkSuppressionsLibraryBuildXml)' != ''">$(ILLinkArgs) --link-attributes "@(ILLinkSuppressionsLibraryBuildXml->'%(FullPath)', '" --link-attributes "')"</ILLinkArgs>
       <!-- suppress warnings with the following codes:
diff --git a/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.cs b/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.cs
index 98798cba791a7c..1cf1b35cf291a1 100644
--- a/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.cs
+++ b/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.cs
@@ -3,16 +3,35 @@
 
 using System;
 using System.Collections.Generic;
+using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.DotNet.XHarness.TestRunners.Common;
 using Microsoft.DotNet.XHarness.TestRunners.Xunit;
+using System.Runtime.CompilerServices;
 
 public class WasmTestRunner : WasmApplicationEntryPoint
 {
     protected int MaxParallelThreadsFromArg { get; set; }
     protected override int? MaxParallelThreads => RunInParallel ? MaxParallelThreadsFromArg : base.MaxParallelThreads;
 
-    public static async Task<int> Main(string[] args)
+#if TARGET_WASI
+    public static int Main(string[] args)
+    {
+        return PollWasiEventLoopUntilResolved((Thread)null!, MainAsync(args));
+
+        [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "PollWasiEventLoopUntilResolved")]
+        static extern int PollWasiEventLoopUntilResolved(Thread t, Task<int> mainTask);
+    }
+
+
+#else
+    public static Task<int> Main(string[] args)
+    {
+        return MainAsync(args);
+    }
+#endif
+
+    public static async Task<int> MainAsync(string[] args)
     {
         if (args.Length == 0)
         {
diff --git a/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.csproj b/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.csproj
index 2f60f08a55c44a..0175da89d58ce2 100644
--- a/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.csproj
+++ b/src/libraries/Common/tests/WasmTestRunner/WasmTestRunner.csproj
@@ -2,7 +2,13 @@
   <PropertyGroup>
     <OutputType>Exe</OutputType>
     <Nullable>enable</Nullable>
-    <TargetFramework>$(NetCoreAppCurrent)</TargetFramework>
+    <TargetFrameworks>$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent)</TargetFrameworks>
+  </PropertyGroup>
+  <!-- DesignTimeBuild requires all the TargetFramework Derived Properties to not be present in the first property group. -->
+  <PropertyGroup>
+    <TargetPlatformIdentifier>$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)'))</TargetPlatformIdentifier>
+    <DefineConstants Condition="'$(TargetPlatformIdentifier)' == 'browser'">$(DefineConstants);TARGET_BROWSER</DefineConstants>
+    <DefineConstants Condition="'$(TargetPlatformIdentifier)' == 'wasi'">$(DefineConstants);TARGET_WASI</DefineConstants>
   </PropertyGroup>
   <ItemGroup>
     <Compile Include="WasmTestRunner.cs" />
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/WasiHttpHandler/WasiHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/WasiHttpHandler/WasiHttpHandler.cs
index ee45aebef883e4..dcccb989910960 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/WasiHttpHandler/WasiHttpHandler.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/WasiHttpHandler/WasiHttpHandler.cs
@@ -314,7 +314,7 @@ ITypes.OutgoingRequest request
                 }
                 else
                 {
-                    await WasiEventLoop.RegisterWasiPollable(future.Subscribe()).ConfigureAwait(false);
+                    await RegisterWasiPollable(future.Subscribe()).ConfigureAwait(false);
                 }
             }
         }
@@ -461,19 +461,22 @@ private static async Task SendContentAsync(HttpContent? content, Stream stream)
             }
         }
 
-        private static class WasiEventLoop
+        private static Task RegisterWasiPollable(IPoll.Pollable pollable)
         {
-            internal static Task RegisterWasiPollable(IPoll.Pollable pollable)
-            {
-                var handle = pollable.Handle;
-                pollable.Handle = 0;
-                return CallRegisterWasiPollable((Thread)null!, handle);
+            var handle = pollable.Handle;
+
+            // this will effectively neutralize Dispose() of the Pollable()
+            // because in the CoreLib we create another instance, which will dispose it
+            pollable.Handle = 0;
+            GC.SuppressFinalize(pollable);
+
+            return CallRegisterWasiPollableHandle((Thread)null!, handle);
 
-                [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "RegisterWasiPollable")]
-                static extern Task CallRegisterWasiPollable(Thread t, int handle);
-            }
         }
 
+        [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "RegisterWasiPollableHandle")]
+        private static extern Task CallRegisterWasiPollableHandle(Thread t, int handle);
+
         private sealed class InputStream : Stream
         {
             private ITypes.IncomingBody body;
@@ -559,8 +562,7 @@ CancellationToken cancellationToken
                             var buffer = result;
                             if (buffer.Length == 0)
                             {
-                                await WasiEventLoop
-                                    .RegisterWasiPollable(stream.Subscribe())
+                                await RegisterWasiPollable(stream.Subscribe())
                                     .ConfigureAwait(false);
                             }
                             else
@@ -697,7 +699,7 @@ CancellationToken cancellationToken
                     var count = (int)stream.CheckWrite();
                     if (count == 0)
                     {
-                        await WasiEventLoop.RegisterWasiPollable(stream.Subscribe()).ConfigureAwait(false);
+                        await RegisterWasiPollable(stream.Subscribe()).ConfigureAwait(false);
                     }
                     else if (offset == limit)
                     {
diff --git a/src/libraries/System.Private.CoreLib/src/ILLink/ILLink.Descriptors.LibraryBuild.WASI.xml b/src/libraries/System.Private.CoreLib/src/ILLink/ILLink.Descriptors.LibraryBuild.WASI.xml
new file mode 100644
index 00000000000000..435d0ef7bdfc70
--- /dev/null
+++ b/src/libraries/System.Private.CoreLib/src/ILLink/ILLink.Descriptors.LibraryBuild.WASI.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<linker>
+	<assembly fullname="System.Private.CoreLib">
+		<!-- these methods are temporarily accessed via UnsafeAccessor from generated code until we have it in public API, probably in WASI preview3 and promises -->
+		<type fullname="System.Threading.Thread">
+			<method name="RegisterWasiPollableHandle" />
+			<method name="PollWasiEventLoopUntilResolved" />
+		</type>
+	</assembly>
+</linker>
\ No newline at end of file
diff --git a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems
index e9a354d8fcc8aa..032299180e78d9 100644
--- a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems
+++ b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems
@@ -64,10 +64,9 @@
     <ILLinkSubstitutionsXmls Include="$(ILLinkSharedDirectory)ILLink.Substitutions.Browser.xml" Condition="'$(TargetsBrowser)' == 'true'" />
     <ILLinkLinkAttributesXmls Include="$(ILLinkSharedDirectory)ILLink.LinkAttributes.Shared.xml" />
     <ILLinkSuppressionsLibraryBuildXml Include="$(ILLinkSharedDirectory)ILLink.Suppressions.LibraryBuild.xml" />
+    <ILLinkDescriptorsLibraryBuildXml Include="$(ILLinkSharedDirectory)ILLink.Descriptors.LibraryBuild.xml" />
+    <ILLinkDescriptorsLibraryBuildXml Include="$(ILLinkSharedDirectory)ILLink.Descriptors.LibraryBuild.WASI.xml" Condition="'$(TargetsWasi)' == 'true'" />
   </ItemGroup>
-  <PropertyGroup>
-    <ILLinkDescriptorsLibraryBuildXml>$(ILLinkSharedDirectory)ILLink.Descriptors.LibraryBuild.xml</ILLinkDescriptorsLibraryBuildXml>
-  </PropertyGroup>
   <ItemGroup>
     <Compile Include="$(MSBuildThisFileDirectory)Internal\AssemblyAttributes.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)Internal\Console.cs" />
@@ -2804,6 +2803,7 @@
   <ItemGroup Condition="'$(TargetsWasi)' == 'true'">
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Wasi\WasiEventLoop.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Wasi\WasiPoll.cs" />
+    <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Wasi\WasiPollWorld.wit.imports.wasi.clocks.v0_2_1.MonotonicClockInterop.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Wasi\WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Wasi\WasiPollWorld.wit.imports.wasi.io.v0_2_1.PollInterop.cs" />
   </ItemGroup>
diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.Unix.cs
index 2535f8a4139668..359c889c975b87 100644
--- a/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.Unix.cs
+++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.Unix.cs
@@ -5,6 +5,7 @@
 using System.Runtime;
 using System.Runtime.InteropServices;
 using Microsoft.Win32.SafeHandles;
+using System.Threading.Tasks;
 
 namespace System.Threading
 {
@@ -12,15 +13,26 @@ public sealed partial class Thread
     {
         // these methods are temporarily accessed via UnsafeAccessor from generated code until we have it in public API, probably in WASI preview3 and promises
 #if TARGET_WASI
-        internal static System.Threading.Tasks.Task RegisterWasiPollable(int handle)
+        internal static System.Threading.Tasks.Task RegisterWasiPollableHandle(int handle)
         {
-            return WasiEventLoop.RegisterWasiPollable(handle);
+            return WasiEventLoop.RegisterWasiPollableHandle(handle);
         }
 
-        internal static void DispatchWasiEventLoop()
+        internal static int PollWasiEventLoopUntilResolved(Task<int> mainTask)
         {
-            WasiEventLoop.DispatchWasiEventLoop();
+            while (!mainTask.IsCompleted)
+            {
+                WasiEventLoop.DispatchWasiEventLoop();
+            }
+            var exception = mainTask.Exception;
+            if (exception is not null)
+            {
+                throw exception;
+            }
+
+            return mainTask.Result;
         }
+
 #endif
 
         // the closest analog to Sleep(0) on Unix is sched_yield
diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiEventLoop.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiEventLoop.cs
index b4e1fda158c6ff..379c5c001c08a6 100644
--- a/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiEventLoop.cs
+++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiEventLoop.cs
@@ -9,43 +9,64 @@ namespace System.Threading
 {
     internal static class WasiEventLoop
     {
-        private static List<(IPoll.Pollable, TaskCompletionSource)> pollables = new();
+        private static List<WeakReference<TaskCompletionSource>> s_pollables = new();
 
-        internal static Task RegisterWasiPollable(int handle)
+        internal static Task RegisterWasiPollableHandle(int handle)
         {
-            var source = new TaskCompletionSource(TaskCreationOptions.AttachedToParent);
-            pollables.Add((new IPoll.Pollable(new IPoll.Pollable.THandle(handle)), source));
-            return source.Task;
+            // note that this is duplicate of the original Pollable
+            // the original should be neutralized without disposing the handle
+            var pollableCpy = new IPoll.Pollable(new IPoll.Pollable.THandle(handle));
+            return RegisterWasiPollable(pollableCpy);
+        }
+
+        internal static Task RegisterWasiPollable(IPoll.Pollable pollable)
+        {
+            var tcs = new TaskCompletionSource(pollable);
+            var weakRef = new WeakReference<TaskCompletionSource>(tcs);
+            s_pollables.Add(weakRef);
+            return tcs.Task;
         }
 
         internal static void DispatchWasiEventLoop()
         {
             ThreadPoolWorkQueue.Dispatch();
 
-            if (WasiEventLoop.pollables.Count > 0)
+            if (s_pollables.Count > 0)
             {
-                var pollables = WasiEventLoop.pollables;
-                WasiEventLoop.pollables = new();
-                var arguments = new List<IPoll.Pollable>();
-                var sources = new List<TaskCompletionSource>();
-                foreach ((var pollable, var source) in pollables)
+                var pollables = s_pollables;
+                s_pollables = new List<WeakReference<TaskCompletionSource>>(pollables.Count);
+                var arguments = new List<IPoll.Pollable>(pollables.Count);
+                var indexes = new List<int>(pollables.Count);
+                for (var i = 0; i < pollables.Count; i++)
                 {
-                    arguments.Add(pollable);
-                    sources.Add(source);
+                    var weakRef = pollables[i];
+                    if (weakRef.TryGetTarget(out TaskCompletionSource? tcs))
+                    {
+                        var pollable = (IPoll.Pollable)tcs!.Task.AsyncState!;
+                        arguments.Add(pollable);
+                        indexes.Add(i);
+                    }
                 }
-                var results = PollInterop.Poll(arguments);
+
+                // this is blocking until at least one pollable resolves
+                var readyIndexes = PollInterop.Poll(arguments);
+
                 var ready = new bool[arguments.Count];
-                foreach (var result in results)
+                foreach (int readyIndex in readyIndexes)
                 {
-                    ready[result] = true;
-                    arguments[(int)result].Dispose();
-                    sources[(int)result].SetResult();
+                    ready[readyIndex] = true;
+                    arguments[readyIndex].Dispose();
+                    var weakRef = pollables[indexes[readyIndex]];
+                    if (weakRef.TryGetTarget(out TaskCompletionSource? tcs))
+                    {
+                        tcs!.SetResult();
+                    }
                 }
                 for (var i = 0; i < arguments.Count; ++i)
                 {
                     if (!ready[i])
                     {
-                        WasiEventLoop.pollables.Add((arguments[i], sources[i]));
+                        s_pollables.Add(pollables[indexes[i]]);
                     }
                 }
             }
diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiPollWorld.wit.imports.wasi.clocks.v0_2_1.MonotonicClockInterop.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiPollWorld.wit.imports.wasi.clocks.v0_2_1.MonotonicClockInterop.cs
new file mode 100644
index 00000000000000..1a0ef026540eb5
--- /dev/null
+++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/WasiPollWorld.wit.imports.wasi.clocks.v0_2_1.MonotonicClockInterop.cs
@@ -0,0 +1,81 @@
+// Generated by `wit-bindgen` 0.29.0. DO NOT EDIT!
+// <auto-generated />
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using System.Collections;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace WasiPollWorld.wit.imports.wasi.clocks.v0_2_1
+{
+    internal static class MonotonicClockInterop {
+
+        internal static class NowWasmInterop
+        {
+            [DllImport("wasi:clocks/monotonic-clock@0.2.1", EntryPoint = "now"), WasmImportLinkage]
+            internal static extern long wasmImportNow();
+
+        }
+
+        internal  static unsafe ulong Now()
+        {
+            var result =  NowWasmInterop.wasmImportNow();
+            return unchecked((ulong)(result));
+
+            //TODO: free alloc handle (interopString) if exists
+        }
+
+        internal static class ResolutionWasmInterop
+        {
+            [DllImport("wasi:clocks/monotonic-clock@0.2.1", EntryPoint = "resolution"), WasmImportLinkage]
+            internal static extern long wasmImportResolution();
+
+        }
+
+        internal  static unsafe ulong Resolution()
+        {
+            var result =  ResolutionWasmInterop.wasmImportResolution();
+            return unchecked((ulong)(result));
+
+            //TODO: free alloc handle (interopString) if exists
+        }
+
+        internal static class SubscribeInstantWasmInterop
+        {
+            [DllImport("wasi:clocks/monotonic-clock@0.2.1", EntryPoint = "subscribe-instant"), WasmImportLinkage]
+            internal static extern int wasmImportSubscribeInstant(long p0);
+
+        }
+
+        internal  static unsafe global::WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.Pollable SubscribeInstant(ulong when)
+        {
+            var result =  SubscribeInstantWasmInterop.wasmImportSubscribeInstant(unchecked((long)(when)));
+            var resource = new global::WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.Pollable(new global::WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.Pollable.THandle(result));
+            return resource;
+
+            //TODO: free alloc handle (interopString) if exists
+        }
+
+        internal static class SubscribeDurationWasmInterop
+        {
+            [DllImport("wasi:clocks/monotonic-clock@0.2.1", EntryPoint = "subscribe-duration"), WasmImportLinkage]
+            internal static extern int wasmImportSubscribeDuration(long p0);
+
+        }
+
+        internal  static unsafe global::WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.Pollable SubscribeDuration(ulong when)
+        {
+            var result =  SubscribeDurationWasmInterop.wasmImportSubscribeDuration(unchecked((long)(when)));
+            var resource = new global::WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.Pollable(new global::WasiPollWorld.wit.imports.wasi.io.v0_2_1.IPoll.Pollable.THandle(result));
+            return resource;
+
+            //TODO: free alloc handle (interopString) if exists
+        }
+
+    }
+}
diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/generate-wasi-poll-bindings.sh b/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/generate-wasi-poll-bindings.sh
index bbc088167661e7..fd20df8e9df20f 100644
--- a/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/generate-wasi-poll-bindings.sh
+++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Wasi/generate-wasi-poll-bindings.sh
@@ -17,6 +17,7 @@ tar xzf v0.2.1.tar.gz
 cat >wasi-http-0.2.1/wit/world.wit <<EOF
 world wasi-poll {
   import wasi:io/poll@0.2.1;
+  import wasi:clocks/monotonic-clock@0.2.1;
 }
 EOF
 wit-bindgen c-sharp -w wasi-poll -r native-aot --internal --skip-support-files wasi-http-0.2.1/wit
diff --git a/src/libraries/tests.proj b/src/libraries/tests.proj
index 336d4518c84526..2d0e7ec5ba4c9f 100644
--- a/src/libraries/tests.proj
+++ b/src/libraries/tests.proj
@@ -581,6 +581,7 @@
     <SmokeTestProject Include="$(MSBuildThisFileDirectory)System.Runtime\tests\System.Buffers.Tests\System.Buffers.Tests.csproj" />
     <SmokeTestProject Include="$(MSBuildThisFileDirectory)System.Runtime\tests\System.Globalization.Tests\Invariant\Invariant.Tests.csproj" />
     <SmokeTestProject Include="$(MSBuildThisFileDirectory)System.Runtime\tests\System.Globalization.Tests\System.Globalization.Tests.csproj" />
+    <SmokeTestProject Include="$(MSBuildThisFileDirectory)System.Runtime\tests\System.Threading.Timer.Tests\System.Threading.Timer.Tests.csproj" />
   </ItemGroup>
 
   <!-- wasi/aot smoke tests -->
diff --git a/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs b/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs
index dce59b97046fd7..47795f22067067 100644
--- a/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs
+++ b/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs
@@ -9,6 +9,9 @@
 
 namespace System.Threading
 {
+#if FEATURE_WASM_MANAGED_THREADS
+#error when compiled with FEATURE_WASM_MANAGED_THREADS, we use TimerQueue.Portable.cs
+#endif
     //
     // Browser-specific implementation of Timer
     // Based on TimerQueue.Portable.cs
diff --git a/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Wasi.Mono.cs b/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Wasi.Mono.cs
index 309f9df6c31b18..20c017cce1fe17 100644
--- a/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Wasi.Mono.cs
+++ b/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Wasi.Mono.cs
@@ -3,30 +3,155 @@
 
 using System.Collections.Generic;
 using System.Diagnostics;
-using System.Runtime.CompilerServices;
-using System.Diagnostics.CodeAnalysis;
-using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using WasiPollWorld.wit.imports.wasi.clocks.v0_2_1;
 
 namespace System.Threading
 {
+#if FEATURE_WASM_MANAGED_THREADS
+#error when compiled with FEATURE_WASM_MANAGED_THREADS, we will use TimerQueue.Portable.cs
+#endif
     //
-    // Wasi-specific implementation of Timer
+    // Wasi implementation of Timer, single-threaded, on top of pollable and wasi:clocks
     // Based on TimerQueue.Portable.cs
     // Not thread safe
     //
-    internal partial class TimerQueue
+    internal sealed partial class TimerQueue
     {
         private static long TickCount64 => Environment.TickCount64;
+        private static List<TimerQueue>? s_scheduledTimers;
+        private static List<TimerQueue>? s_scheduledTimersToFire;
+        private static long s_shortestDueTimeMs = long.MaxValue;
 
+        // this means that it's in the s_scheduledTimers collection, not that it's the one which would run on the next TimeoutCallback
+        private bool _isScheduled;
+        private long _scheduledDueTimeMs;
         private TimerQueue(int _)
         {
-            throw new PlatformNotSupportedException();
         }
-#pragma warning disable CA1822 // Mark members as static
+
+        private static void TimerHandler(object _)
+        {
+            try
+            {
+                s_shortestDueTimeMs = long.MaxValue;
+
+                long currentTimeMs = TickCount64;
+                SetNextTimer(PumpTimerQueue(currentTimeMs), currentTimeMs);
+            }
+            catch (Exception e)
+            {
+                Environment.FailFast("TimerQueue.TimerHandler failed", e);
+            }
+        }
+
+        // this is called with shortest of timers scheduled on the particular TimerQueue
         private bool SetTimer(uint actualDuration)
         {
-            throw new PlatformNotSupportedException();
+            Debug.Assert((int)actualDuration >= 0);
+            long currentTimeMs = TickCount64;
+            if (!_isScheduled)
+            {
+                s_scheduledTimers ??= new List<TimerQueue>(Instances.Length);
+                s_scheduledTimersToFire ??= new List<TimerQueue>(Instances.Length);
+                s_scheduledTimers.Add(this);
+                _isScheduled = true;
+            }
+
+            _scheduledDueTimeMs = currentTimeMs + (int)actualDuration;
+
+            SetNextTimer(ShortestDueTime(), currentTimeMs);
+
+            return true;
+        }
+
+        // shortest time of all TimerQueues
+        private static unsafe void SetNextTimer(long shortestDueTimeMs, long currentTimeMs)
+        {
+            if (shortestDueTimeMs == long.MaxValue)
+            {
+                return;
+            }
+
+            // this also covers s_shortestDueTimeMs = long.MaxValue when none is scheduled
+            if (s_shortestDueTimeMs > shortestDueTimeMs)
+            {
+                s_shortestDueTimeMs = shortestDueTimeMs;
+                ulong shortestWaitMs = (ulong)Math.Max((long)(shortestDueTimeMs - currentTimeMs), 0);
+
+                // `SubscribeDuration` expects nanoseconds:
+                var pollable = MonotonicClockInterop.SubscribeDuration(shortestWaitMs * 1000 * 1000);
+                Task task = WasiEventLoop.RegisterWasiPollable(pollable);
+                task.ContinueWith(TimerHandler, TaskScheduler.Default);
+            }
+        }
+
+        private static long ShortestDueTime()
+        {
+            if (s_scheduledTimers == null)
+            {
+                return long.MaxValue;
+            }
+
+            long shortestDueTimeMs = long.MaxValue;
+            var timers = s_scheduledTimers!;
+            for (int i = timers.Count - 1; i >= 0; --i)
+            {
+                TimerQueue timer = timers[i];
+                if (timer._scheduledDueTimeMs < shortestDueTimeMs)
+                {
+                    shortestDueTimeMs = timer._scheduledDueTimeMs;
+                }
+            }
+
+            return shortestDueTimeMs;
+        }
+
+        private static long PumpTimerQueue(long currentTimeMs)
+        {
+            if (s_scheduledTimersToFire == null)
+            {
+                return ShortestDueTime();
+            }
+
+            List<TimerQueue> timersToFire = s_scheduledTimersToFire!;
+            List<TimerQueue> timers;
+            timers = s_scheduledTimers!;
+            long shortestDueTimeMs = long.MaxValue;
+            for (int i = timers.Count - 1; i >= 0; --i)
+            {
+                TimerQueue timer = timers[i];
+                long waitDurationMs = timer._scheduledDueTimeMs - currentTimeMs;
+                if (waitDurationMs <= 0)
+                {
+                    timer._isScheduled = false;
+                    timersToFire.Add(timer);
+
+                    int lastIndex = timers.Count - 1;
+                    if (i != lastIndex)
+                    {
+                        timers[i] = timers[lastIndex];
+                    }
+                    timers.RemoveAt(lastIndex);
+                    continue;
+                }
+
+                if (timer._scheduledDueTimeMs < shortestDueTimeMs)
+                {
+                    shortestDueTimeMs = timer._scheduledDueTimeMs;
+                }
+            }
+
+            if (timersToFire.Count > 0)
+            {
+                foreach (TimerQueue timerToFire in timersToFire)
+                {
+                    timerToFire.FireNextTimers();
+                }
+                timersToFire.Clear();
+            }
+
+            return shortestDueTimeMs;
         }
-#pragma warning restore CA1822
     }
 }
diff --git a/src/mono/mono.proj b/src/mono/mono.proj
index 88ae8c08861970..b45f8150d251a2 100644
--- a/src/mono/mono.proj
+++ b/src/mono/mono.proj
@@ -346,11 +346,15 @@ JS_ENGINES = [NODE_JS]
     <Error Text="Expected version: %(_ExpectedVersionLines.Identity) and actual version: %(_ActualVersionLines.Identity) of WASI SDK does not match. Please delete $(WASI_SDK_PATH) folder to provision a new version."
       Condition="'$(ActualWasiSdkVersion)' != '$(ExpectedWasiSdkVersion)'" />
 
-    <!-- LLVM in WASI SDK 22 will call wasm-opt when found on the PATH. But it will fail because wasm-opt can't read the WASM components.
-    After we upgrade to WASI SDK 23.x, we could use no-wasm-opt LLVM option to avoid this issue.
+    <!-- LLVM in WASI SDK 24 will call wasm-opt when found on the PATH. But it will fail because wasm-opt can't read the WASM components.
+    After we upgrade to WASI SDK with LLVM 19, we could use no-wasm-opt LLVM option to avoid this issue.
     See https://github.com/llvm/llvm-project/pull/98373
+    See https://github.com/dotnet/runtime/issues/104773
     -->
-    <Exec Command="wasm-opt --version" IgnoreExitCode="true" IgnoreStandardErrorWarningFormat="true" StandardOutputImportance="Low" >
+    <Exec Command="wasm-opt --version" IgnoreExitCode="true" 
+      IgnoreStandardErrorWarningFormat="true" 
+      StandardErrorImportance="low"
+      StandardOutputImportance="Low" >
       <Output TaskParameter="ExitCode" PropertyName="_WasmOptExitCode"/>
     </Exec>
     <Error Text="Found wasm-opt tool on the PATH. Please remove it to avoid failures during compilation into wasm32-wasip2 target as WASM components, which is not supported by wasm-opt tool. See https://github.com/llvm/llvm-project/pull/95208#issuecomment-2220400454"
diff --git a/src/mono/sample/wasi/http-p2/Program.cs b/src/mono/sample/wasi/http-p2/Program.cs
index f2687d511fc151..98df1a50a0537c 100644
--- a/src/mono/sample/wasi/http-p2/Program.cs
+++ b/src/mono/sample/wasi/http-p2/Program.cs
@@ -8,50 +8,33 @@
 using System.Threading;
 using System.Runtime.CompilerServices;
 
-public class Test
+// keep in sync with src\mono\wasi\testassets\Http.cs
+public static class WasiMainWrapper
 {
-    public static int Main(string[] args)
-    {
-        var task = Work();
-        while (!task.IsCompleted)
-        {
-            WasiEventLoop.DispatchWasiEventLoop();
-        }
-        var exception = task.Exception;
-        if (exception is not null)
-        {
-            throw exception;
-        }
-
-        return 0;
-    }
-
-    public static async Task Work()
+    public static async Task<int> MainAsync(string[] args)
     {
         using HttpClient client = new();
         client.Timeout = Timeout.InfiniteTimeSpan;
         client.DefaultRequestHeaders.Accept.Clear();
-        client.DefaultRequestHeaders.Accept.Add(
-            new MediaTypeWithQualityHeaderValue("application/vnd.github.v3+json"));
-        client.DefaultRequestHeaders.Add("User-Agent", ".NET Foundation Repository Reporter");
+        client.DefaultRequestHeaders.Add("User-Agent", "dotnet WASI unit test");
         
-        var query="https://api.github.com/orgs/dotnet/repos?per_page=1";
+        var query="https://corefx-net-http11.azurewebsites.net/Echo.ashx";
         var json = await client.GetStringAsync(query);
 
         Console.WriteLine();
         Console.WriteLine("GET "+query);
         Console.WriteLine();
         Console.WriteLine(json);
+
+        return 0;
     }
 
-    private static class WasiEventLoop
+    public static int Main(string[] args)
     {
-        internal static void DispatchWasiEventLoop()
-        {
-            CallDispatchWasiEventLoop((Thread)null!);
+        return PollWasiEventLoopUntilResolved((Thread)null!, MainAsync(args));
 
-            [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "DispatchWasiEventLoop")]
-            static extern void CallDispatchWasiEventLoop(Thread t);
-        }
+        [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "PollWasiEventLoopUntilResolved")]
+        static extern int PollWasiEventLoopUntilResolved(Thread t, Task<int> mainTask);
     }
+
 }
diff --git a/src/mono/wasi/Wasi.Build.Tests/BuildTestBase.cs b/src/mono/wasi/Wasi.Build.Tests/BuildTestBase.cs
index ae3fab94bbdc88..62f23c2648899c 100644
--- a/src/mono/wasi/Wasi.Build.Tests/BuildTestBase.cs
+++ b/src/mono/wasi/Wasi.Build.Tests/BuildTestBase.cs
@@ -693,10 +693,14 @@ public static int Main()
             .MultiplyWithSingleArgs(true, false) /*aot*/
             .UnwrapItemsAsArrays();
 
-        protected CommandResult RunWithoutBuild(string config, string id)
+        protected CommandResult RunWithoutBuild(string config, string id, bool enableHttp = false)
         {
             // wasmtime --wasi http is necessary because the default dotnet.wasm (without native rebuild depends on wasi:http world)
-            string runArgs = $"run --no-build -c {config} --forward-exit-code --extra-host-arg=--wasi --extra-host-arg=http";
+            string runArgs = $"run --no-build -c {config} --forward-exit-code";
+            if (enableHttp)
+            {
+                runArgs += " --extra-host-arg=--wasi --extra-host-arg=http";
+            }
             runArgs += " x y z";
             int expectedExitCode = 42;
             CommandResult res = new RunCommand(s_buildEnv, _testOutput, label: id)
diff --git a/src/mono/wasi/Wasi.Build.Tests/HttpTests.cs b/src/mono/wasi/Wasi.Build.Tests/HttpTests.cs
new file mode 100644
index 00000000000000..1b977ef9e39f25
--- /dev/null
+++ b/src/mono/wasi/Wasi.Build.Tests/HttpTests.cs
@@ -0,0 +1,69 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using Xunit;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+using Wasm.Build.Tests;
+
+#nullable enable
+
+namespace Wasi.Build.Tests;
+
+public class HttpTests : BuildTestBase
+{
+    public HttpTests(ITestOutputHelper output, SharedBuildPerTestClassFixture buildContext)
+        : base(output, buildContext)
+    {
+    }
+
+    [Theory]
+    [MemberData(nameof(TestDataForConsolePublishAndRun))]
+    public void HttpBuildThenRunThenPublish(string config, bool singleFileBundle, bool aot)
+    {
+        string id = $"{config}_{GetRandomId()}";
+        string projectFile = CreateWasmTemplateProject(id, "wasiconsole");
+        string projectName = Path.GetFileNameWithoutExtension(projectFile);
+        File.Copy(Path.Combine(BuildEnvironment.TestAssetsPath, "Http.cs"), Path.Combine(_projectDir!, "Program.cs"), true);
+
+        var buildArgs = new BuildArgs(projectName, config, aot, id, null);
+        buildArgs = ExpandBuildArgs(buildArgs);
+
+        string extraProperties = "<PublishTrimmed>true</PublishTrimmed>";
+        if (aot)
+            extraProperties += "<RunAOTCompilation>true</RunAOTCompilation><_WasmDevel>false</_WasmDevel>";
+        if (singleFileBundle)
+            extraProperties += "<WasmSingleFileBundle>true</WasmSingleFileBundle>";
+        if (!string.IsNullOrEmpty(extraProperties))
+            AddItemsPropertiesToProject(projectFile, extraProperties);
+
+        BuildProject(buildArgs,
+                    id: id,
+                    new BuildProjectOptions(
+                        DotnetWasmFromRuntimePack: true,
+                        CreateProject: false,
+                        Publish: false,
+                        TargetFramework: BuildTestBase.DefaultTargetFramework));
+        RunWithoutBuild(config, id, true);
+
+        if (!_buildContext.TryGetBuildFor(buildArgs, out BuildProduct? product))
+            throw new XunitException($"Test bug: could not get the build product in the cache");
+
+        File.Move(product!.LogFile, Path.ChangeExtension(product.LogFile!, ".first.binlog"));
+
+        _testOutput.WriteLine($"{Environment.NewLine}Publishing with no changes ..{Environment.NewLine}");
+
+        BuildProject(buildArgs,
+                    id: id,
+                    new BuildProjectOptions(
+                        DotnetWasmFromRuntimePack: true,
+                        CreateProject: false,
+                        Publish: true,
+                        TargetFramework: BuildTestBase.DefaultTargetFramework,
+                        UseCache: false,
+                        ExpectSuccess: !(config == "Debug" && aot)));
+    }
+
+}
diff --git a/src/mono/wasi/Wasi.Build.Tests/WasiTemplateTests.cs b/src/mono/wasi/Wasi.Build.Tests/WasiTemplateTests.cs
index 5a909d54b13744..e1887ab525f086 100644
--- a/src/mono/wasi/Wasi.Build.Tests/WasiTemplateTests.cs
+++ b/src/mono/wasi/Wasi.Build.Tests/WasiTemplateTests.cs
@@ -50,7 +50,7 @@ public void ConsoleBuildAndRunAOT(string config, bool singleFileBundle)
                         CreateProject: false,
                         Publish: false,
                         TargetFramework: BuildTestBase.DefaultTargetFramework));
-        RunWithoutBuild(config, id);
+        RunWithoutBuild(config, id, true);
     }
 
     [Theory]
@@ -80,7 +80,7 @@ public void ConsoleBuildThenRunThenPublish(string config, bool singleFileBundle,
                         CreateProject: false,
                         Publish: false,
                         TargetFramework: BuildTestBase.DefaultTargetFramework));
-        RunWithoutBuild(config, id);
+        RunWithoutBuild(config, id, true);
 
         if (!_buildContext.TryGetBuildFor(buildArgs, out BuildProduct? product))
             throw new XunitException($"Test bug: could not get the build product in the cache");
diff --git a/src/mono/wasi/testassets/Http.cs b/src/mono/wasi/testassets/Http.cs
new file mode 100644
index 00000000000000..d8043bf017ca7e
--- /dev/null
+++ b/src/mono/wasi/testassets/Http.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Net.Http.Headers;
+using System.Net.Http;
+using System.Threading.Tasks;
+using System.Threading;
+using System.Runtime.CompilerServices;
+
+// keep in sync with src\mono\sample\wasi\http-p2\Program.cs
+public static class WasiMainWrapper
+{
+    public static async Task<int> MainAsync(string[] args)
+    {
+        Console.WriteLine("Hello, Wasi Console!");
+        for (int i = 0; i < args.Length; i ++)
+            Console.WriteLine($"args[{i}] = {args[i]}");
+
+        using HttpClient client = new();
+        client.Timeout = Timeout.InfiniteTimeSpan;
+        client.DefaultRequestHeaders.Accept.Clear();
+        client.DefaultRequestHeaders.Add("User-Agent", "dotnet WASI unit test");
+        
+        var query="https://corefx-net-http11.azurewebsites.net/Echo.ashx";
+        var json = await client.GetStringAsync(query);
+
+        Console.WriteLine();
+        Console.WriteLine("GET "+query);
+        Console.WriteLine();
+        Console.WriteLine(json);
+
+        return 42;
+    }
+
+    public static int Main(string[] args)
+    {
+        return PollWasiEventLoopUntilResolved((Thread)null!, MainAsync(args));
+
+        [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "PollWasiEventLoopUntilResolved")]
+        static extern int PollWasiEventLoopUntilResolved(Thread t, Task<int> mainTask);
+    }
+}
