diff --git a/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs
new file mode 100644
index 00000000..9c8933f6
--- /dev/null
+++ b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs
@@ -0,0 +1,184 @@
+// SPDX-FileCopyrightText: 2026 Unity Technologies and the glTFast authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace GLTFast.Tests.Import
+{
+ ///
+ /// Tests for glTF-binary JSON chunk decoding. A large JSON chunk is decoded off the main
+ /// thread when the defer agent signals the predicted decode time exceeds the frame budget;
+ /// small chunks keep decoding in-frame. Either path must produce identical results —
+ /// including correct UTF-8 handling across the thread boundary — and honor cancellation.
+ ///
+ [Category("Import")]
+ class BinaryJsonDecodeTests
+ {
+ /// Unicode node names crossing the threaded decode must survive intact.
+ static readonly string[] k_NodeNames = { "Café", "node/中文", "emoji❤♻" };
+
+ ///
+ /// A defer agent that always defers, routing a binary JSON chunk of any size through
+ /// the threaded decode path.
+ ///
+ class AlwaysDeferAgent : IDeferAgent
+ {
+ public bool ShouldDefer() => true;
+ public bool ShouldDefer(float duration) => true;
+ public async Task BreakPoint() => await Task.Yield();
+ public async Task BreakPoint(float duration) => await Task.Yield();
+ }
+
+ [UnityEngine.TestTools.UnityTest]
+ public IEnumerator ThreadedJsonDecodeMatchesInFrameDecode()
+ {
+ yield return AsyncWrapper.WaitForTask(ThreadedJsonDecodeMatchesInFrameDecodeInternal());
+ }
+
+ static async Task ThreadedJsonDecodeMatchesInFrameDecodeInternal()
+ {
+ var glb = CreateLargeBinaryGltf();
+
+ // UninterruptedDeferAgent never defers => in-frame decode; AlwaysDeferAgent defers
+ // any predicted duration => threaded decode. Results must be identical.
+ var inFrame = await LoadAndDescribeAsync(glb, new UninterruptedDeferAgent());
+ var threaded = await LoadAndDescribeAsync(glb, new AlwaysDeferAgent());
+
+ if (inFrame != threaded)
+ Debug.Log($"IN-FRAME:\n{inFrame}\n\nTHREADED:\n{threaded}");
+ Assert.AreEqual(inFrame, threaded,
+ "Threaded binary JSON decode produced a different scene than in-frame decode.");
+
+ // Unicode must survive the thread boundary byte-for-byte.
+ foreach (var name in k_NodeNames)
+ StringAssert.Contains(name, threaded,
+ $"Node name '{name}' was corrupted by the threaded JSON decode.");
+ }
+
+ [UnityEngine.TestTools.UnityTest]
+ public IEnumerator ThreadedJsonDecodeHonorsCancellation()
+ {
+ yield return AsyncWrapper.WaitForTask(ThreadedJsonDecodeHonorsCancellationInternal());
+ }
+
+ static async Task ThreadedJsonDecodeHonorsCancellationInternal()
+ {
+ var glb = CreateLargeBinaryGltf();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ using var gltf = new GltfImport(deferAgent: new AlwaysDeferAgent());
+ try
+ {
+ await gltf.LoadGltfBinary(glb, cancellationToken: cts.Token);
+ Assert.Fail("Loading with a cancelled token did not raise OperationCanceledException.");
+ }
+ catch (OperationCanceledException)
+ {
+ // expected — cancellation before/at the threaded decode must surface cleanly.
+ }
+ }
+
+ static async Task LoadAndDescribeAsync(byte[] glb, IDeferAgent deferAgent)
+ {
+ // The import stays alive until after the description — disposing destroys its meshes.
+ using var gltf = new GltfImport(deferAgent: deferAgent);
+ var success = await gltf.LoadGltfBinary(glb);
+ Assert.IsTrue(success, "Loading the binary test asset failed.");
+ var root = new GameObject("BinaryJsonDecodeResult");
+ try
+ {
+ success = await gltf.InstantiateMainSceneAsync(root.transform);
+ Assert.IsTrue(success, "Instantiation failed.");
+ return DescribeHierarchy(root);
+ }
+ finally
+ {
+ UnityEngine.Object.Destroy(root);
+ }
+ }
+
+ /// Stable, order-independent hierarchy description: path per transform plus its
+ /// renderer's mesh shape when present.
+ static string DescribeHierarchy(GameObject root)
+ {
+ var lines = new List();
+ foreach (var transform in root.GetComponentsInChildren(true))
+ {
+ if (transform == root.transform)
+ continue;
+ var parts = new List();
+ for (var t = transform; t != null && t != root.transform; t = t.parent)
+ parts.Add(t.name);
+ parts.Reverse();
+ var line = string.Join("/", parts);
+ var filter = transform.GetComponent();
+ if (filter != null && filter.sharedMesh != null)
+ line += $" [verts={filter.sharedMesh.vertexCount} submeshes={filter.sharedMesh.subMeshCount}]";
+ lines.Add(line);
+ }
+ lines.Sort(StringComparer.Ordinal);
+ return string.Join("\n", lines);
+ }
+
+ ///
+ /// Self-contained glTF-binary whose JSON chunk is ~1 MB (padded via root "extras"), so
+ /// its predicted decode time is meaningful, carrying a single-triangle mesh (data-URI
+ /// buffer, no BIN chunk) and unicode node names.
+ ///
+ static byte[] CreateLargeBinaryGltf()
+ {
+ var buffer = new List();
+ foreach (var value in new float[] { 0, 0, 0, 1, 0, 0, 0, 1, 0 })
+ buffer.AddRange(BitConverter.GetBytes(value));
+ foreach (var index in new ushort[] { 0, 1, 2 })
+ buffer.AddRange(BitConverter.GetBytes(index));
+ var bufferUri = $"data:application/octet-stream;base64,{Convert.ToBase64String(buffer.ToArray())}";
+
+ var padding = new string('x', 1024 * 1024);
+
+ var json = @"{
+ ""asset"": { ""version"": ""2.0"" },
+ ""extras"": { ""padding"": """ + padding + @""" },
+ ""scene"": 0,
+ ""scenes"": [ { ""nodes"": [0] } ],
+ ""nodes"": [
+ { ""name"": """ + k_NodeNames[0] + @""", ""children"": [1] },
+ { ""name"": """ + k_NodeNames[1].Replace("/", "\\/") + @""", ""children"": [2] },
+ { ""name"": """ + k_NodeNames[2] + @""", ""mesh"": 0 }
+ ],
+ ""meshes"": [ { ""primitives"": [ { ""attributes"": { ""POSITION"": 0 }, ""indices"": 1 } ] } ],
+ ""accessors"": [
+ { ""bufferView"": 0, ""componentType"": 5126, ""count"": 3, ""type"": ""VEC3"", ""min"": [0,0,0], ""max"": [1,1,0] },
+ { ""bufferView"": 1, ""componentType"": 5123, ""count"": 3, ""type"": ""SCALAR"" }
+ ],
+ ""bufferViews"": [
+ { ""buffer"": 0, ""byteOffset"": 0, ""byteLength"": 36 },
+ { ""buffer"": 0, ""byteOffset"": 36, ""byteLength"": 6 }
+ ],
+ ""buffers"": [ { ""uri"": """ + bufferUri + @""", ""byteLength"": 42 } ]
+}";
+
+ var jsonBytes = Encoding.UTF8.GetBytes(json);
+ var paddedLength = (jsonBytes.Length + 3) & ~3;
+
+ var glb = new List();
+ glb.AddRange(BitConverter.GetBytes(0x46546C67u)); // 'glTF'
+ glb.AddRange(BitConverter.GetBytes(2u)); // version
+ glb.AddRange(BitConverter.GetBytes((uint)(12 + 8 + paddedLength))); // total length
+ glb.AddRange(BitConverter.GetBytes((uint)paddedLength)); // chunk length
+ glb.AddRange(BitConverter.GetBytes(0x4E4F534Au)); // 'JSON'
+ glb.AddRange(jsonBytes);
+ for (var i = jsonBytes.Length; i < paddedLength; i++)
+ glb.Add(0x20); // pad with spaces
+ return glb.ToArray();
+ }
+ }
+}
diff --git a/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta
new file mode 100644
index 00000000..d1032727
--- /dev/null
+++ b/Packages/com.unity.cloud.gltfast.tests/Tests/Runtime/Scripts/Import/BinaryJsonDecodeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8855ebf0e74cc4895be043ab4d0f9f49
\ No newline at end of file
diff --git a/Packages/com.unity.cloud.gltfast/CHANGELOG.md b/Packages/com.unity.cloud.gltfast/CHANGELOG.md
index 6752c2a6..1c5c5dcb 100644
--- a/Packages/com.unity.cloud.gltfast/CHANGELOG.md
+++ b/Packages/com.unity.cloud.gltfast/CHANGELOG.md
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- Main thread stall when loading glTF-binary files with large JSON chunks: the JSON string decode now runs on a background thread (gated by the defer agent, like the JSON parse), instead of always executing synchronously on the calling thread.
+
+## [6.19.0] - 2026-05-19
+
### Added
- (Add-Ons) Import glTF animations to custom animation systems.
- [IAnimationProcessor](xref:GLTFast.Animations.IAnimationProcessor) — animation clips conversion
diff --git a/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs b/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs
index b11dfd0b..8102b383 100755
--- a/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs
+++ b/Packages/com.unity.cloud.gltfast/Runtime/Scripts/GltfImport.cs
@@ -2075,11 +2075,38 @@ CancellationToken cancellationToken
{
Assert.IsNull(Root);
- Profiler.BeginSample("GetJSON");
var bytesStream = bytes.ToUnmanagedMemoryStream((uint)index, chLength);
var reader = new StreamReader(bytesStream);
- var json = await reader.ReadToEndAsync();
- Profiler.EndSample();
+ string json = null;
+ var predictedTime = chLength / (float)k_JsonParseSpeed;
+#if GLTFAST_THREADS && !MEASURE_TIMINGS
+ if (DeferAgent.ShouldDefer(predictedTime))
+ {
+ // ReadToEndAsync completes synchronously on a memory stream
+ // => decode in a thread.
+ try
+ {
+ json = await Task.Run(() =>
+ {
+ Profiler.BeginSample("GetJSON");
+ var result = reader.ReadToEnd();
+ Profiler.EndSample();
+ return result;
+ }, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ cancellationToken.ThrowIfCancellationRequestedWithTracking();
+ }
+ }
+ else
+#endif
+ {
+ // Chunk is small enough to decode within the frame budget.
+ Profiler.BeginSample("GetJSON");
+ json = await reader.ReadToEndAsync();
+ Profiler.EndSample();
+ }
var success = await ParseJsonAndLoadBuffers(json, cancellationToken);