diff --git a/IntegrationTests/lib.js b/IntegrationTests/lib.js
index 6c08cddde..0172250d4 100644
--- a/IntegrationTests/lib.js
+++ b/IntegrationTests/lib.js
@@ -128,6 +128,7 @@ class ThreadRegistry {
 
         worker.on("error", (error) => {
             console.error(`Worker thread ${tid} error:`, error);
+            throw error;
         });
         this.workers.set(tid, worker);
         worker.postMessage({ selfFilePath, module, programName, memory, tid, startArg });
diff --git a/Package.swift b/Package.swift
index c33d7e71b..37c2d1f3c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -58,6 +58,11 @@ let package = Package(
             ]
         ),
         .target(name: "_CJavaScriptEventLoopTestSupport"),
+
+        .testTarget(
+            name: "JavaScriptKitTests",
+            dependencies: ["JavaScriptKit"]
+        ),
         .testTarget(
           name: "JavaScriptEventLoopTestSupportTests",
           dependencies: [
diff --git a/Sources/JavaScriptBigIntSupport/Int64+I64.swift b/Sources/JavaScriptBigIntSupport/Int64+I64.swift
index fdd1d544f..e361e72e9 100644
--- a/Sources/JavaScriptBigIntSupport/Int64+I64.swift
+++ b/Sources/JavaScriptBigIntSupport/Int64+I64.swift
@@ -1,13 +1,13 @@
 import JavaScriptKit
 
 extension UInt64: JavaScriptKit.ConvertibleToJSValue, JavaScriptKit.TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.BigUint64Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.BigUint64Array.function! }
 
     public var jsValue: JSValue { .bigInt(JSBigInt(unsigned: self)) }
 }
 
 extension Int64: JavaScriptKit.ConvertibleToJSValue, JavaScriptKit.TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.BigInt64Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.BigInt64Array.function! }
 
     public var jsValue: JSValue { .bigInt(JSBigInt(self)) }
 }
diff --git a/Sources/JavaScriptKit/BasicObjects/JSArray.swift b/Sources/JavaScriptKit/BasicObjects/JSArray.swift
index 90dba72d8..a431eb9a5 100644
--- a/Sources/JavaScriptKit/BasicObjects/JSArray.swift
+++ b/Sources/JavaScriptKit/BasicObjects/JSArray.swift
@@ -2,7 +2,9 @@
 /// class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)
 /// that exposes its properties in a type-safe and Swifty way.
 public class JSArray: JSBridgedClass {
-    public static let constructor = JSObject.global.Array.function
+    public static var constructor: JSFunction? { _constructor }
+    @LazyThreadLocal(initialize: { JSObject.global.Array.function })
+    private static var _constructor: JSFunction?
 
     static func isArray(_ object: JSObject) -> Bool {
         constructor!.isArray!(object).boolean!
diff --git a/Sources/JavaScriptKit/BasicObjects/JSDate.swift b/Sources/JavaScriptKit/BasicObjects/JSDate.swift
index 767374125..da31aca06 100644
--- a/Sources/JavaScriptKit/BasicObjects/JSDate.swift
+++ b/Sources/JavaScriptKit/BasicObjects/JSDate.swift
@@ -8,7 +8,9 @@
  */
 public final class JSDate: JSBridgedClass {
     /// The constructor function used to create new `Date` objects.
-    public static let constructor = JSObject.global.Date.function
+    public static var constructor: JSFunction? { _constructor }
+    @LazyThreadLocal(initialize: { JSObject.global.Date.function })
+    private static var _constructor: JSFunction?
 
     /// The underlying JavaScript `Date` object.
     public let jsObject: JSObject
diff --git a/Sources/JavaScriptKit/BasicObjects/JSError.swift b/Sources/JavaScriptKit/BasicObjects/JSError.swift
index e9b006c81..559618e15 100644
--- a/Sources/JavaScriptKit/BasicObjects/JSError.swift
+++ b/Sources/JavaScriptKit/BasicObjects/JSError.swift
@@ -4,7 +4,9 @@
  */
 public final class JSError: Error, JSBridgedClass {
     /// The constructor function used to create new JavaScript `Error` objects.
-    public static let constructor = JSObject.global.Error.function
+    public static var constructor: JSFunction? { _constructor }
+    @LazyThreadLocal(initialize: { JSObject.global.Error.function })
+    private static var _constructor: JSFunction?
 
     /// The underlying JavaScript `Error` object.
     public let jsObject: JSObject
diff --git a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift
index 2168292f7..bc80cd25c 100644
--- a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift
+++ b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift
@@ -47,7 +47,10 @@ public class JSTypedArray<Element>: JSBridgedClass, ExpressibleByArrayLiteral wh
     /// - Parameter array: The array that will be copied to create a new instance of TypedArray
     public convenience init(_ array: [Element]) {
         let jsArrayRef = array.withUnsafeBufferPointer { ptr in
-            swjs_create_typed_array(Self.constructor!.id, ptr.baseAddress, Int32(array.count))
+            // Retain the constructor function to avoid it being released before calling `swjs_create_typed_array`
+            withExtendedLifetime(Self.constructor!) { ctor in
+                swjs_create_typed_array(ctor.id, ptr.baseAddress, Int32(array.count))
+            }
         }
         self.init(unsafelyWrapping: JSObject(id: jsArrayRef))
     }
@@ -140,21 +143,27 @@ func valueForBitWidth<T>(typeName: String, bitWidth: Int, when32: T) -> T {
 }
 
 extension Int: TypedArrayElement {
-    public static var typedArrayClass: JSFunction =
+    public static var typedArrayClass: JSFunction { _typedArrayClass }
+    @LazyThreadLocal(initialize: {
         valueForBitWidth(typeName: "Int", bitWidth: Int.bitWidth, when32: JSObject.global.Int32Array).function!
+    })
+    private static var _typedArrayClass: JSFunction
 }
 
 extension UInt: TypedArrayElement {
-    public static var typedArrayClass: JSFunction =
+    public static var typedArrayClass: JSFunction { _typedArrayClass }
+    @LazyThreadLocal(initialize: {
         valueForBitWidth(typeName: "UInt", bitWidth: Int.bitWidth, when32: JSObject.global.Uint32Array).function!
+    })
+    private static var _typedArrayClass: JSFunction
 }
 
 extension Int8: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Int8Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Int8Array.function! }
 }
 
 extension UInt8: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Uint8Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Uint8Array.function! }
 }
 
 /// A wrapper around [the JavaScript `Uint8ClampedArray`
@@ -165,26 +174,26 @@ public class JSUInt8ClampedArray: JSTypedArray<UInt8> {
 }
 
 extension Int16: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Int16Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Int16Array.function! }
 }
 
 extension UInt16: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Uint16Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Uint16Array.function! }
 }
 
 extension Int32: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Int32Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Int32Array.function! }
 }
 
 extension UInt32: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Uint32Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Uint32Array.function! }
 }
 
 extension Float32: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Float32Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Float32Array.function! }
 }
 
 extension Float64: TypedArrayElement {
-    public static var typedArrayClass = JSObject.global.Float64Array.function!
+    public static var typedArrayClass: JSFunction { JSObject.global.Float64Array.function! }
 }
 #endif
diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSBigInt.swift b/Sources/JavaScriptKit/FundamentalObjects/JSBigInt.swift
index f3687246e..a8867f95c 100644
--- a/Sources/JavaScriptKit/FundamentalObjects/JSBigInt.swift
+++ b/Sources/JavaScriptKit/FundamentalObjects/JSBigInt.swift
@@ -1,6 +1,6 @@
 import _CJavaScriptKit
 
-private let constructor = JSObject.global.BigInt.function!
+private var constructor: JSFunction { JSObject.global.BigInt.function! }
 
 /// A wrapper around [the JavaScript `BigInt`
 /// class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)
@@ -30,9 +30,9 @@ public final class JSBigInt: JSObject {
 
     public func clamped(bitSize: Int, signed: Bool) -> JSBigInt {
         if signed {
-            return constructor.asIntN!(bitSize, self).bigInt!
+            return constructor.asIntN(bitSize, self).bigInt!
         } else {
-            return constructor.asUintN!(bitSize, self).bigInt!
+            return constructor.asUintN(bitSize, self).bigInt!
         }
     }
 }
diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift
index 82b1e6502..143cbdb39 100644
--- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift
+++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift
@@ -1,5 +1,13 @@
 import _CJavaScriptKit
 
+#if arch(wasm32)
+    #if canImport(wasi_pthread)
+        import wasi_pthread
+    #endif
+#else
+    import Foundation // for pthread_t on non-wasi platforms
+#endif
+
 /// `JSObject` represents an object in JavaScript and supports dynamic member lookup.
 /// Any member access like `object.foo` will dynamically request the JavaScript and Swift
 /// runtime bridge library for a member with the specified name in this object.
@@ -16,11 +24,43 @@ import _CJavaScriptKit
 /// reference counting system.
 @dynamicMemberLookup
 public class JSObject: Equatable {
+    internal static var constructor: JSFunction { _constructor }
+    @LazyThreadLocal(initialize: { JSObject.global.Object.function! })
+    internal static var _constructor: JSFunction
+
     @_spi(JSObject_id)
     public var id: JavaScriptObjectRef
+
+#if compiler(>=6.1) && _runtime(_multithreaded)
+    private let ownerThread: pthread_t
+#endif
+
     @_spi(JSObject_id)
     public init(id: JavaScriptObjectRef) {
         self.id = id
+#if compiler(>=6.1) && _runtime(_multithreaded)
+        self.ownerThread = pthread_self()
+#endif
+    }
+
+    /// Asserts that the object is being accessed from the owner thread.
+    ///
+    /// - Parameter hint: A string to provide additional context for debugging.
+    ///
+    /// NOTE: Accessing a `JSObject` from a thread other than the thread it was created on
+    /// is a programmer error and will result in a runtime assertion failure because JavaScript
+    /// object spaces are not shared across threads backed by Web Workers.
+    private func assertOnOwnerThread(hint: @autoclosure () -> String) {
+        #if compiler(>=6.1) && _runtime(_multithreaded)
+        precondition(pthread_equal(ownerThread, pthread_self()) != 0, "JSObject is being accessed from a thread other than the owner thread: \(hint())")
+        #endif
+    }
+
+    /// Asserts that the two objects being compared are owned by the same thread.
+    private static func assertSameOwnerThread(lhs: JSObject, rhs: JSObject, hint: @autoclosure () -> String) {
+        #if compiler(>=6.1) && _runtime(_multithreaded)
+        precondition(pthread_equal(lhs.ownerThread, rhs.ownerThread) != 0, "JSObject is being accessed from a thread other than the owner thread: \(hint())")
+        #endif
     }
 
 #if !hasFeature(Embedded)
@@ -79,32 +119,56 @@ public class JSObject: Equatable {
     /// - Parameter name: The name of this object's member to access.
     /// - Returns: The value of the `name` member of this object.
     public subscript(_ name: String) -> JSValue {
-        get { getJSValue(this: self, name: JSString(name)) }
-        set { setJSValue(this: self, name: JSString(name), value: newValue) }
+        get {
+            assertOnOwnerThread(hint: "reading '\(name)' property")
+            return getJSValue(this: self, name: JSString(name))
+        }
+        set {
+            assertOnOwnerThread(hint: "writing '\(name)' property")
+            setJSValue(this: self, name: JSString(name), value: newValue)
+        }
     }
 
     /// Access the `name` member dynamically through JavaScript and Swift runtime bridge library.
     /// - Parameter name: The name of this object's member to access.
     /// - Returns: The value of the `name` member of this object.
     public subscript(_ name: JSString) -> JSValue {
-        get { getJSValue(this: self, name: name) }
-        set { setJSValue(this: self, name: name, value: newValue) }
+        get {
+            assertOnOwnerThread(hint: "reading '<<JSString>>' property")
+            return getJSValue(this: self, name: name)
+        }
+        set {
+            assertOnOwnerThread(hint: "writing '<<JSString>>' property")
+            setJSValue(this: self, name: name, value: newValue)
+        }
     }
 
     /// Access the `index` member dynamically through JavaScript and Swift runtime bridge library.
     /// - Parameter index: The index of this object's member to access.
     /// - Returns: The value of the `index` member of this object.
     public subscript(_ index: Int) -> JSValue {
-        get { getJSValue(this: self, index: Int32(index)) }
-        set { setJSValue(this: self, index: Int32(index), value: newValue) }
+        get {
+            assertOnOwnerThread(hint: "reading '\(index)' property")
+            return getJSValue(this: self, index: Int32(index))
+        }
+        set {
+            assertOnOwnerThread(hint: "writing '\(index)' property")
+            setJSValue(this: self, index: Int32(index), value: newValue)
+        }
     }
 
     /// Access the `symbol` member dynamically through JavaScript and Swift runtime bridge library.
     /// - Parameter symbol: The name of this object's member to access.
     /// - Returns: The value of the `name` member of this object.
     public subscript(_ name: JSSymbol) -> JSValue {
-        get { getJSValue(this: self, symbol: name) }
-        set { setJSValue(this: self, symbol: name, value: newValue) }
+        get {
+            assertOnOwnerThread(hint: "reading '<<JSSymbol>>' property")
+            return getJSValue(this: self, symbol: name)
+        }
+        set {
+            assertOnOwnerThread(hint: "writing '<<JSSymbol>>' property")
+            setJSValue(this: self, symbol: name, value: newValue)
+        }
     }
 
 #if !hasFeature(Embedded)
@@ -134,7 +198,8 @@ public class JSObject: Equatable {
     /// - Parameter constructor: The constructor function to check.
     /// - Returns: The result of `instanceof` in the JavaScript environment.
     public func isInstanceOf(_ constructor: JSFunction) -> Bool {
-        swjs_instanceof(id, constructor.id)
+        assertOnOwnerThread(hint: "calling 'isInstanceOf'")
+        return swjs_instanceof(id, constructor.id)
     }
 
     static let _JS_Predef_Value_Global: JavaScriptObjectRef = 0
@@ -143,16 +208,15 @@ public class JSObject: Equatable {
     /// This allows access to the global properties and global names by accessing the `JSObject` returned.
     public static var global: JSObject { return _global }
 
-    // `JSObject` storage itself is immutable, and use of `JSObject.global` from other
-    // threads maintains the same semantics as `globalThis` in JavaScript.
-    #if compiler(>=5.10)
-    nonisolated(unsafe)
-    static let _global = JSObject(id: _JS_Predef_Value_Global)
-    #else
-    static let _global = JSObject(id: _JS_Predef_Value_Global)
-    #endif
+    @LazyThreadLocal(initialize: {
+        return JSObject(id: _JS_Predef_Value_Global)
+    })
+    private static var _global: JSObject
 
-    deinit { swjs_release(id) }
+    deinit {
+        assertOnOwnerThread(hint: "deinitializing")
+        swjs_release(id)
+    }
 
     /// Returns a Boolean value indicating whether two values point to same objects.
     ///
@@ -160,6 +224,7 @@ public class JSObject: Equatable {
     ///   - lhs: A object to compare.
     ///   - rhs: Another object to compare.
     public static func == (lhs: JSObject, rhs: JSObject) -> Bool {
+        assertSameOwnerThread(lhs: lhs, rhs: rhs, hint: "comparing two JSObjects for equality")
         return lhs.id == rhs.id
     }
 
diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSSymbol.swift b/Sources/JavaScriptKit/FundamentalObjects/JSSymbol.swift
index d768b6675..567976c70 100644
--- a/Sources/JavaScriptKit/FundamentalObjects/JSSymbol.swift
+++ b/Sources/JavaScriptKit/FundamentalObjects/JSSymbol.swift
@@ -47,17 +47,17 @@ public class JSSymbol: JSObject {
 }
 
 extension JSSymbol {
-    public static let asyncIterator: JSSymbol! = Symbol.asyncIterator.symbol
-    public static let hasInstance: JSSymbol! = Symbol.hasInstance.symbol
-    public static let isConcatSpreadable: JSSymbol! = Symbol.isConcatSpreadable.symbol
-    public static let iterator: JSSymbol! = Symbol.iterator.symbol
-    public static let match: JSSymbol! = Symbol.match.symbol
-    public static let matchAll: JSSymbol! = Symbol.matchAll.symbol
-    public static let replace: JSSymbol! = Symbol.replace.symbol
-    public static let search: JSSymbol! = Symbol.search.symbol
-    public static let species: JSSymbol! = Symbol.species.symbol
-    public static let split: JSSymbol! = Symbol.split.symbol
-    public static let toPrimitive: JSSymbol! = Symbol.toPrimitive.symbol
-    public static let toStringTag: JSSymbol! = Symbol.toStringTag.symbol
-    public static let unscopables: JSSymbol! = Symbol.unscopables.symbol
+    public static var asyncIterator: JSSymbol! { Symbol.asyncIterator.symbol }
+    public static var hasInstance: JSSymbol! { Symbol.hasInstance.symbol }
+    public static var isConcatSpreadable: JSSymbol! { Symbol.isConcatSpreadable.symbol }
+    public static var iterator: JSSymbol! { Symbol.iterator.symbol }
+    public static var match: JSSymbol! { Symbol.match.symbol }
+    public static var matchAll: JSSymbol! { Symbol.matchAll.symbol }
+    public static var replace: JSSymbol! { Symbol.replace.symbol }
+    public static var search: JSSymbol! { Symbol.search.symbol }
+    public static var species: JSSymbol! { Symbol.species.symbol }
+    public static var split: JSSymbol! { Symbol.split.symbol }
+    public static var toPrimitive: JSSymbol! { Symbol.toPrimitive.symbol }
+    public static var toStringTag: JSSymbol! { Symbol.toStringTag.symbol }
+    public static var unscopables: JSSymbol! { Symbol.unscopables.symbol }
 }
diff --git a/Sources/JavaScriptKit/JSValueDecoder.swift b/Sources/JavaScriptKit/JSValueDecoder.swift
index 73ee9310c..b2cf7b2a3 100644
--- a/Sources/JavaScriptKit/JSValueDecoder.swift
+++ b/Sources/JavaScriptKit/JSValueDecoder.swift
@@ -35,9 +35,8 @@ private struct _Decoder: Decoder {
 }
 
 private enum Object {
-    static let ref = JSObject.global.Object.function!
     static func keys(_ object: JSObject) -> [String] {
-        let keys = ref.keys!(object).array!
+        let keys = JSObject.constructor.keys!(object).array!
         return keys.map { $0.string! }
     }
 }
@@ -249,4 +248,4 @@ public class JSValueDecoder {
         return try T(from: decoder)
     }
 }
-#endif
\ No newline at end of file
+#endif
diff --git a/Sources/JavaScriptKit/ThreadLocal.swift b/Sources/JavaScriptKit/ThreadLocal.swift
new file mode 100644
index 000000000..9f5751c96
--- /dev/null
+++ b/Sources/JavaScriptKit/ThreadLocal.swift
@@ -0,0 +1,129 @@
+#if arch(wasm32)
+#if canImport(wasi_pthread)
+import wasi_pthread
+#endif
+#elseif canImport(Darwin)
+import Darwin
+#elseif canImport(Glibc)
+import Glibc
+#else
+#error("Unsupported platform")
+#endif
+
+/// A property wrapper that provides thread-local storage for a value.
+///
+/// The value is stored in a thread-local variable, which is a separate copy for each thread.
+@propertyWrapper
+final class ThreadLocal<Value>: Sendable {
+#if compiler(>=6.1) && _runtime(_multithreaded)
+    /// The wrapped value stored in the thread-local storage.
+    /// The initial value is `nil` for each thread.
+    var wrappedValue: Value? {
+        get {
+            guard let pointer = pthread_getspecific(key) else {
+                return nil
+            }
+            return fromPointer(pointer)
+        }
+        set {
+            if let oldPointer = pthread_getspecific(key) {
+                release(oldPointer)
+            }
+            if let newValue = newValue {
+                let pointer = toPointer(newValue)
+                pthread_setspecific(key, pointer)
+            }
+        }
+    }
+
+    private let key: pthread_key_t
+    private let toPointer: @Sendable (Value) -> UnsafeMutableRawPointer
+    private let fromPointer: @Sendable (UnsafeMutableRawPointer) -> Value
+    private let release: @Sendable (UnsafeMutableRawPointer) -> Void
+
+    /// A constructor that requires `Value` to be `AnyObject` to be
+    /// able to store the value directly in the thread-local storage.
+    init() where Value: AnyObject {
+        var key = pthread_key_t()
+        pthread_key_create(&key, nil)
+        self.key = key
+        self.toPointer = { Unmanaged.passRetained($0).toOpaque() }
+        self.fromPointer = { Unmanaged<Value>.fromOpaque($0).takeUnretainedValue() }
+        self.release = { Unmanaged<Value>.fromOpaque($0).release() }
+    }
+
+    private class Box {
+        let value: Value
+        init(_ value: Value) {
+            self.value = value
+        }
+    }
+
+    /// A constructor that doesn't require `Value` to be `AnyObject` but
+    /// boxing the value in heap-allocated memory.
+    init(boxing _: Void) {
+        var key = pthread_key_t()
+        pthread_key_create(&key, nil)
+        self.key = key
+        self.toPointer = {
+            let box = Box($0)
+            let pointer = Unmanaged.passRetained(box).toOpaque()
+            return pointer
+        }
+        self.fromPointer = {
+            let box = Unmanaged<Box>.fromOpaque($0).takeUnretainedValue()
+            return box.value
+        }
+        self.release = { Unmanaged<Box>.fromOpaque($0).release() }
+    }
+#else
+    // Fallback implementation for platforms that don't support pthread
+    private class SendableBox: @unchecked Sendable {
+        var value: Value? = nil
+    }
+    private let _storage = SendableBox()
+    var wrappedValue: Value? {
+        get { _storage.value }
+        set { _storage.value = newValue }
+    }
+
+    init() where Value: AnyObject {
+        wrappedValue = nil
+    }
+    init(boxing _: Void) {
+        wrappedValue = nil
+    }
+#endif
+
+    deinit {
+        preconditionFailure("ThreadLocal can only be used as an immortal storage, cannot be deallocated")
+    }
+}
+
+/// A property wrapper that lazily initializes a thread-local value
+/// for each thread that accesses the value.
+@propertyWrapper
+final class LazyThreadLocal<Value>: Sendable {
+    private let storage: ThreadLocal<Value>
+
+    var wrappedValue: Value {
+        if let value = storage.wrappedValue {
+            return value
+        }
+        let value = initialValue()
+        storage.wrappedValue = value
+        return value
+    }
+
+    private let initialValue: @Sendable () -> Value
+
+    init(initialize: @Sendable @escaping () -> Value) where Value: AnyObject {
+        self.storage = ThreadLocal()
+        self.initialValue = initialize
+    }
+
+    init(initialize: @Sendable @escaping () -> Value) {
+        self.storage = ThreadLocal(boxing: ())
+        self.initialValue = initialize
+    }
+}
diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift
index a31c783d3..645c6e388 100644
--- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift
+++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift
@@ -1,7 +1,7 @@
 #if compiler(>=6.1) && _runtime(_multithreaded)
 import XCTest
-import JavaScriptKit
 import _CJavaScriptKit // For swjs_get_worker_thread_id
+@testable import JavaScriptKit
 @testable import JavaScriptEventLoop
 
 @_extern(wasm, module: "JavaScriptEventLoopTestSupportTests", name: "isMainThread")
@@ -150,5 +150,68 @@ final class WebWorkerTaskExecutorTests: XCTestCase {
         }
         executor.terminate()
     }
+
+    func testThreadLocalPerThreadValues() async throws {
+        struct Check {
+            @ThreadLocal(boxing: ())
+            static var value: Int?
+        }
+        let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
+        XCTAssertNil(Check.value)
+        Check.value = 42
+        XCTAssertEqual(Check.value, 42)
+
+        let task = Task(executorPreference: executor) {
+            XCTAssertEqual(Check.value, nil)
+            Check.value = 100
+            XCTAssertEqual(Check.value, 100)
+            return Check.value
+        }
+        let result = await task.value
+        XCTAssertEqual(result, 100)
+        XCTAssertEqual(Check.value, 42)
+    }
+
+    func testLazyThreadLocalPerThreadInitialization() async throws {
+        struct Check {
+            static var valueToInitialize = 42
+            static var countOfInitialization = 0
+            @LazyThreadLocal(initialize: {
+                countOfInitialization += 1
+                return valueToInitialize
+            })
+            static var value: Int
+        }
+        let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
+        XCTAssertEqual(Check.countOfInitialization, 0)
+        XCTAssertEqual(Check.value, 42)
+        XCTAssertEqual(Check.countOfInitialization, 1)
+
+        Check.valueToInitialize = 100
+
+        let task = Task(executorPreference: executor) {
+            XCTAssertEqual(Check.countOfInitialization, 1)
+            XCTAssertEqual(Check.value, 100)
+            XCTAssertEqual(Check.countOfInitialization, 2)
+            return Check.value
+        }
+        let result = await task.value
+        XCTAssertEqual(result, 100)
+        XCTAssertEqual(Check.countOfInitialization, 2)
+    }
+
+/*
+    func testDeinitJSObjectOnDifferentThread() async throws {
+        let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
+
+        var object: JSObject? = JSObject.global.Object.function!.new()
+        let task = Task(executorPreference: executor) {
+            object = nil
+            _ = object
+        }
+        await task.value
+        executor.terminate()
+    }
+*/
 }
 #endif
diff --git a/Tests/JavaScriptKitTests/ThreadLocalTests.swift b/Tests/JavaScriptKitTests/ThreadLocalTests.swift
new file mode 100644
index 000000000..55fcdadb4
--- /dev/null
+++ b/Tests/JavaScriptKitTests/ThreadLocalTests.swift
@@ -0,0 +1,46 @@
+import XCTest
+@testable import JavaScriptKit
+
+final class ThreadLocalTests: XCTestCase {
+    class MyHeapObject {}
+    struct MyStruct {
+        var object: MyHeapObject
+    }
+
+    func testLeak() throws {
+        struct Check {
+            static let value = ThreadLocal<MyHeapObject>()
+            static let value2 = ThreadLocal<MyStruct>(boxing: ())
+        }
+        weak var weakObject: MyHeapObject?
+        do {
+            let object = MyHeapObject()
+            weakObject = object
+            Check.value.wrappedValue = object
+            XCTAssertNotNil(Check.value.wrappedValue)
+            XCTAssertTrue(Check.value.wrappedValue === object)
+            Check.value.wrappedValue = nil
+        }
+        XCTAssertNil(weakObject)
+
+        weak var weakObject2: MyHeapObject?
+        do {
+            let object = MyHeapObject()
+            weakObject2 = object
+            Check.value2.wrappedValue = MyStruct(object: object)
+            XCTAssertNotNil(Check.value2.wrappedValue)
+            XCTAssertTrue(Check.value2.wrappedValue!.object === object)
+            Check.value2.wrappedValue = nil
+        }
+        XCTAssertNil(weakObject2)
+    }
+
+    func testLazyThreadLocal() throws {
+        struct Check {
+            static let value = LazyThreadLocal(initialize: { MyHeapObject() })
+        }
+        let object1 = Check.value.wrappedValue
+        let object2 = Check.value.wrappedValue
+        XCTAssertTrue(object1 === object2)
+    }
+}