DEV Community

Witold Wozniak
Witold Wozniak

Posted on

Resolving MissingMethodException in Newtonsoft.Json

A test passed in CI and failed on my machine. Same commit, same code, two different outcomes, and the difference turned out to live in the Global Assembly Cache rather than anywhere in the repository. This is a write-up of how a strong-named assembly in the GAC can shadow the copy your application ships, why MissingMethodException is the symptom, and the one-line change that resolves it.

The problem

A unit test failed locally with:

System.MissingMethodException: Method not found:
'System.String Newtonsoft.Json.Linq.JToken.ToString(Newtonsoft.Json.Formatting)'
Enter fullscreen mode Exit fullscreen mode

The same test passed in Jenkins and GitHub Actions. The code was compiled against a recent Newtonsoft.Json, and the build output folder contained a Newtonsoft.Json.dll that does expose JToken.ToString(Formatting). The method existed in the assembly sitting next to the application. The runtime was not loading that assembly.

A gap that appears on developer workstations but never in CI points away from the code and toward the environment. The environment, in this case, was the GAC.

The explanation

On .NET Framework, the runtime resolves a strong-named assembly by its identity: name, public key token, culture, and assembly version. When an assembly with a matching identity is present in the GAC, the GAC copy is preferred over a copy in the application's own directory.

Newtonsoft.Json keeps the same strong-name identity across the entire 13.x line:

  • AssemblyVersion is 13.0.0.0 for every 13.0.x release
  • the public key token is identical across them

Two different builds — say 13.0.1 and a later 13.0.x — are therefore indistinguishable to the runtime's binding logic. They present the same identity. A binding redirect cannot tell them apart, because there is nothing to redirect between. So if any 13.0.x is registered in the GAC, it satisfies the reference, and the runtime loads it in preference to the version you shipped.

Because all 13.x releases share one identity, an application cannot enforce that it runs against the build it was compiled and tested against. An older release with a known vulnerability, present in the GAC, will be loaded ahead of the patched copy in the application folder, and there is no binding-redirect mechanism to prevent it. The upstream issues linked below raise exactly this point.


The two assemblies are not byte-identical. Method signatures shifted across 13.x patch releases, including on JToken.ToString and JToken.WriteTo. The set of overloads present in 13.0.1 is not exactly the set present in a later 13.0.x.

That produces the failure. The compiler binds the call against the overload set in the version you reference. At runtime the GAC copy is loaded, its overload set is different, the specific overload the call was compiled against is not there, and you get MissingMethodException: a method present at compile time and absent at run time, with no code change between the two.

Continuous integration never reproduces it because the build agents have no such GAC entry. They load the assembly from the application folder, which is the one the build referenced. Green every time.


The older Newtonsoft.Json in the GAC was not put there by the application, and on a managed corporate machine it cannot simply be removed. It is registered and held open by background agents — endpoint security, device management, and update services — that run for the life of the session and keep file handles on the assembly the whole time.

Searching the GAC path in Resource Monitor shows the holders directly. On a typical managed Windows laptop they are the usual fleet-management software:

Associated Handles                              Search: Newtonsoft.Json.dll
─────────────────────────────────────────────────────────────────────────────
Image                                PID    Type   Handle Name
─────────────────────────────────────────────────────────────────────────────
OktaVerify.exe                       2137   File   C:\Windows\Microsoft.NET\
Dell.Update.SubAgent.exe             15288  File     assembly\GAC_MSIL\
WorkspaceONEHubHealthMonitoring.exe  7696   File     Newtonsoft.Json\
TaskScheduler.exe                    8372   File     v4.0_13.0.0.0__
ServiceShell.exe                     26004  File     30ad4fe6b2a6aeed\
(and others)                                         Newtonsoft.Json.dll
─────────────────────────────────────────────────────────────────────────────
Enter fullscreen mode Exit fullscreen mode

The copy is effectively immutable: you do not control the processes pinning it, and you cannot unregister an assembly the operating system reports as in use. Any fix has to assume the GAC copy stays exactly where it is.

The fix

Since the GAC copy cannot be moved, the call has to bind to an overload that exists in both versions. Both ToString and WriteTo have a params JsonConverter[] overload that has been present across the 13.x line. The single-argument overloads are the ones whose presence varies between patch releases. Passing the converter argument explicitly forces binding to the stable overload.

For ToString:

// before — binds to ToString(Formatting), absent from the older GAC copy
jToken.ToString(Formatting.None)

// after — binds to ToString(Formatting, params JsonConverter[])
jToken.ToString(Formatting.None, [])

// in older C# versions
jToken.ToString(Formatting.None, Array.Empty<JsonConverter>())

Enter fullscreen mode Exit fullscreen mode

For WriteTo:

// before — binds to WriteTo(JsonWriter)
jToken.WriteTo(writer)

// after — binds to WriteTo(JsonWriter, params JsonConverter[])
jToken.WriteTo(writer, Array.Empty<JsonConverter>())
Enter fullscreen mode Exit fullscreen mode

The empty converter array means no converters are applied, so the output is byte-for-byte identical to the original call. Nothing about serialization behavior changes. The only thing that changes is which overload the compiler selects, and the selected overload is one the older GAC assembly also has. The maintainer recommends this same workaround on the upstream issue.

It is a targeted mitigation, not a cure. It resolves the specific calls that break, and it leaves the underlying resolution behavior untouched — any other call site reaching for a newer-only overload will fail the same way until it gets the same treatment.


There is a second option that avoids the unstable type entirely. Instead of asking the token to render itself, hand it to the static serializer:

// before — JToken instance method, overload set varies across the GAC copy
jToken.ToString(Formatting.None)

// after — static method, signature stable across 13.x
JsonConvert.SerializeObject(jToken, Formatting.None)
Enter fullscreen mode Exit fullscreen mode

JsonConvert.SerializeObject(object, Formatting) is the relevant difference. It is a static method whose signature has held across the 13.x line, and it is a different member from the one breaking, so the varying JToken.ToString overloads in the GAC copy never enter resolution. The token is serialized through the normal JsonSerializer path and the result is the JSON you expect.

The tradeoff is that this routes through default JsonSerializerSettings rather than the token's own write path. For a plain JToken the output matches ToString(Formatting.None), but if the application configures global default settings, output is no longer guaranteed byte-identical. Where that matters, prefer the explicit-overload fix, which changes only overload selection and leaves serialization behavior alone.


Both options are local mitigations. The root cause is the shared assembly version, and that is being addressed upstream. 13.0.5-beta1 is out as of December 30, 2025 and is intended to resolve the binding problem permanently; the maintainer has asked people hitting this to try it and report back. Until a stable release lands and reaches the machines registering the GAC copy, the changes above are the reliable way to keep working.

The narrowing that got me here was mechanical, with help from a senior colleague:

  1. Confirmed the failure was in the JToken.ToString(Formatting.None) path.
  2. Checked the build output folder: a newer Newtonsoft.Json.dll was present and did contain the member.
  3. Checked the GAC: an older copy was present.
  4. Compared against CI, where the test passes, which left local assembly resolution as the only difference between the two environments.
  5. Matched the behavior against the upstream issues describing the same strong-name and GAC collision.

When a build succeeds everywhere except one class of machine, the difference is usually not in the code. On a managed Windows workstation the GAC is shared, writable by other software, and authoritative for strong-named assemblies, which means a dependency you never installed can quietly take precedence over the one you shipped.

References

Top comments (0)