Skip to content

perf: replace Activator.CreateInstance with direct constructor in ApiResponse.Create#2071

Merged
ChrisPulman merged 2 commits intoreactiveui:mainfrom
james-s-tayler:perf/optimize-apiresponse-create
Mar 26, 2026
Merged

perf: replace Activator.CreateInstance with direct constructor in ApiResponse.Create#2071
ChrisPulman merged 2 commits intoreactiveui:mainfrom
james-s-tayler:perf/optimize-apiresponse-create

Conversation

@james-s-tayler
Copy link
Copy Markdown
Contributor

@james-s-tayler james-s-tayler commented Mar 26, 2026

Summary

Replace the reflection-based Activator.CreateInstance(typeof(ApiResponse<TBody>), ...) with a direct new ApiResponse<TBody>(...) constructor call in ApiResponse.Create<T, TBody>(), and change the content parameter type from object? to TBody? to eliminate boxing for value types.

This is a small, focused change that removes per-call reflection overhead on every ApiResponse<T> creation.

Why ApiResponse?

GitHub code search shows ApiResponse<T> is the second most common return type in Refit interfaces (after plain Task<T>):

  • Task<ApiResponse<T>> found in 76+ unique repos (search cap hit at 100 file matches)
  • Task<IApiResponse<T>> found in 47+ unique repos
  • This is not a niche pattern — it's the recommended way to access HTTP status codes, headers, and error info alongside typed content

The typical Refit user profile (from GitHub search):

  • Uses AddRefitClient<T>() with DI (97 repos)
  • Uses System.Text.Json (default serializer, 91+ repos explicitly)
  • Returns a mix of Task<T> and Task<ApiResponse<T>>

What Changed

Refit/ApiResponse.cs — 2 changes in the Create method:

  1. Eliminate reflection: Activator.CreateInstance(typeof(ApiResponse<TBody>), ...)new ApiResponse<TBody>(...)
  2. Eliminate boxing: Parameter type object? contentTBody? content

Refit/RequestBuilderImplementation.cs — 1 change at the call site:

  1. Fix null literal for unconstrained generic: nulldefault (required because TBody? for unconstrained generics doesn't accept null literal for value types)

Benchmark Results

BenchmarkDotNet v0.14.0, .NET 9.0.14, AMD Ryzen 7 7700 (8C/16T)

Baseline (before)

Scenario Mean (μs) Allocated
OK / Get / SystemTextJson 6.137 9.84 KB
OK / Get / NewtonsoftJson 9.105 19.52 KB
OK / Post / SystemTextJson 6.732 10.89 KB
OK / Post / NewtonsoftJson 14.204 32.03 KB
Error / Get / SystemTextJson 2.634 12.66 KB
Error / Get / NewtonsoftJson 2.549 12.66 KB
Error / Post / SystemTextJson 3.273 13.70 KB
Error / Post / NewtonsoftJson 7.561 25.16 KB

After optimization

Scenario Mean (μs) Allocated
OK / Get / SystemTextJson 5.949 9.52 KB
OK / Get / NewtonsoftJson 8.986 19.20 KB
OK / Post / SystemTextJson 6.669 10.56 KB
OK / Post / NewtonsoftJson 13.814 31.72 KB
Error / Get / SystemTextJson 2.350 12.33 KB
Error / Get / NewtonsoftJson 2.358 12.33 KB
Error / Post / SystemTextJson 3.111 13.38 KB
Error / Post / NewtonsoftJson 7.362 24.84 KB

Per-scenario improvement

Scenario Δ Latency Δ Allocations
OK / Get / STJ -3.1% -3.3%
OK / Get / Newtonsoft -1.3% -1.6%
OK / Post / STJ -0.9% -3.0%
OK / Post / Newtonsoft -2.7% -1.0%
Error / Get / STJ -10.8% -2.6%
Error / Get / Newtonsoft -7.5% -2.6%
Error / Post / STJ -4.9% -2.3%
Error / Post / Newtonsoft -2.6% -1.3%
Overall average -3.1% -1.9%

The error path sees the largest gains (-10.8%) since the reflection overhead is a larger fraction of total execution time on shorter code paths.

Consistent ~328 bytes/call allocation reduction across all scenarios from eliminating the boxing of TBody to object.

Commits

  1. bench: add net9.0 target to Refit.Benchmarks — Adds net9.0 alongside net8.0 as a benchmark target
  2. perf: replace Activator.CreateInstance with direct constructor — The optimization itself + call site nulldefault fix

Test results

All 501 tests in Refit.Tests and API approval tests pass on net9.0 with this change.

Solo Yolo added 2 commits March 27, 2026 01:25
Add net9.0 alongside net8.0 as a benchmark target framework
to enable performance measurement on the latest .NET runtime.
…Response.Create

Replace the reflection-based `Activator.CreateInstance(typeof(ApiResponse<TBody>), ...)`
with a direct `new ApiResponse<TBody>(...)` constructor call, and change the `content`
parameter type from `object?` to `TBody?` to eliminate boxing for value types.

This removes per-call reflection overhead on every ApiResponse<T> creation, which
is the second most common return type pattern in Refit usage (found in 76+ public repos).

Benchmark results (BenchmarkDotNet v0.14.0, .NET 9.0.14, AMD Ryzen 7 7700):

| Scenario              | Before (μs) | After (μs) | Δ Latency | Before (KB) | After (KB) | Δ Alloc |
|-----------------------|-------------|------------|-----------|-------------|------------|---------|
| OK/Get/STJ            | 6.137       | 5.949      | -3.1%     | 9.84        | 9.52       | -3.3%   |
| OK/Get/Newtonsoft     | 9.105       | 8.986      | -1.3%     | 19.52       | 19.20      | -1.6%   |
| OK/Post/STJ           | 6.732       | 6.669      | -0.9%     | 10.89       | 10.56      | -3.0%   |
| OK/Post/Newtonsoft    | 14.204      | 13.814     | -2.7%     | 32.03       | 31.72      | -1.0%   |
| Error/Get/STJ         | 2.634       | 2.350      | -10.8%    | 12.66       | 12.33      | -2.6%   |
| Error/Get/Newtonsoft  | 2.549       | 2.358      | -7.5%     | 12.66       | 12.33      | -2.6%   |
| Error/Post/STJ        | 3.273       | 3.111      | -4.9%     | 13.70       | 13.38      | -2.3%   |
| Error/Post/Newtonsoft | 7.561       | 7.362      | -2.6%     | 25.16       | 24.84      | -1.3%   |

Average: -3.1% latency, -1.9% allocations across all scenarios.
Error path sees the largest gains (-10.8%) since the reflection overhead
is a larger fraction of total execution time.
@james-s-tayler james-s-tayler force-pushed the perf/optimize-apiresponse-create branch from a6c0161 to 8d089b1 Compare March 26, 2026 16:28
@ChrisPulman ChrisPulman merged commit 7251290 into reactiveui:main Mar 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants