Retrofit makes HTTP calls on Android feel almost effortless. You define an interface, annotate the methods, plug in a converter, and it takes care of the rest. But one of the situations where this smooth pipeline can break is when your server returns an empty response body. Your JSON converter throws a parsing exception and your app crashes.
In this article, we will look at why this happens, what Retrofit already handles for you, and how to build a small custom converter factory (called NullOnEmptyConverterFactory by convention) that fixes the problem cleanly across your entire API.
The Error You Will Actually See
Suppose you have a Retrofit interface that is declared to return a User object after creating a new account:
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): User
The server acknowledges the creation with a 201 Created, but sends no body back. When your code runs, you see this in Logcat:
java.io.EOFException: End of input at line 1 column 1 path $
at com.google.gson.stream.JsonReader.nextNonWhitespace(JsonReader.java:1414)
at com.google.gson.stream.JsonReader.peek(JsonReader.java:429)
at retrofit2.converter.gson.GsonResponseBodyConverter.convert(GsonResponseBodyConverter.java:33)
at retrofit2.OkHttpCall.parseResponse(OkHttpCall.java:154)
...
Gson (or whichever JSON converter you use) is trying to parse the empty stream, sees no content, and reports it as malformed JSON. The crash is confusing because the network call itself succeeded. It is the deserialization step that failed.
Why This Happens, and Why Only Sometimes
One detail worth pausing on is that Retrofit does handle empty responses, but only for two specific HTTP status codes: 204 No Content and 205 Reset Content. To see why, here is a simplified sketch of what Retrofit does internally when a response arrives:
// Simplified sketch of what Retrofit does in OkHttpCall.parseResponse
Response<T> parseResponse(okhttp3.Response rawResponse) {
int code = rawResponse.code();
// Empty-body status codes short-circuit here
if (code == 204 || code == 205) {
return Response.success(null, rawResponse);
}
// Every other status code goes through the converter chain
T body = responseConverter.convert(rawResponse.body());
return Response.success(body, rawResponse);
}
For 204 and 205, Retrofit returns null for the body immediately, without ever calling your converter. The problem shows up when a server returns an empty body under a different status code, most commonly 200 OK or 201 Created. In those cases, Retrofit assumes there is content to parse, hands the stream to your converter, and the converter fails.
Strictly speaking, a server should return 204 when there is no content, but in practice many APIs return 200 or 201 with an empty body. You either need to accept that reality and handle it on the client, or push a fix upstream. Since you often cannot change the backend, we need a client-side solution.
A Quick Refresher on Converter.Factory
Before writing the fix, it helps to recall how Retrofit picks a converter. When you call addConverterFactory(...) on the builder, you are adding to an ordered list. For each response type, Retrofit walks that list from top to bottom, asking each factory whether it can produce a Converter for the given type. The first factory that returns a non-null converter wins.
That ordering is what makes the null-on-empty pattern work. If we register our own factory before the JSON converter, we get first crack at every response. We can then decide whether to short-circuit (when the body is empty) or delegate to the JSON converter (when it is not).
This is a straightforward application of the decorator pattern to Retrofit's converter chain.
The Implementation
Here is the Kotlin implementation:
class NullOnEmptyConverterFactory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *> {
val delegate: Converter<ResponseBody, Any> =
retrofit.nextResponseBodyConverter(this, type, annotations)
return Converter<ResponseBody, Any?> { body ->
if (body.contentLength() == 0L) null else delegate.convert(body)
}
}
}
The Java equivalent, for teams still on Java:
public final class NullOnEmptyConverterFactory extends Converter.Factory {
@Override
public Converter<ResponseBody, ?> responseBodyConverter(
Type type,
Annotation[] annotations,
Retrofit retrofit) {
final Converter<ResponseBody, ?> delegate =
retrofit.nextResponseBodyConverter(this, type, annotations);
return body -> body.contentLength() == 0 ? null : delegate.convert(body);
}
}
And here is how you wire it into your Retrofit instance:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(NullOnEmptyConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()
Notice that NullOnEmptyConverterFactory is registered first. This is not optional. If Gson comes first, it will claim every type and our factory will never be reached.
How It Works, Step by Step
Let us trace what happens when a response comes in.
- Retrofit receives the HTTP response and enters
parseResponse. - If the status code is
204or205, Retrofit returnsnullwithout consulting the converter chain at all. - For any other status code, Retrofit needs a
Converter<ResponseBody, T>to translate the body into the declared return type. It walks the registered factories in order. - Our
NullOnEmptyConverterFactorysits at the front of the list, so Retrofit asks it first. - Inside our factory, we call
retrofit.nextResponseBodyConverter(this, type, annotations). The first argument,this, fills Retrofit'sskipPastparameter. It tells Retrofit to look for the next factory that can handle this type, skipping ourselves.
To see why this argument matters, imagine we passed null instead. Retrofit's nextResponseBodyConverter would start iterating the factory list from the beginning, find our NullOnEmptyConverterFactory again, and call its responseBodyConverter method. That method would call nextResponseBodyConverter(null, ...) once more, which would find us yet again, and so on. The call stack would look roughly like this:
NullOnEmptyConverterFactory.responseBodyConverter
-> Retrofit.nextResponseBodyConverter(skipPast = null)
-> NullOnEmptyConverterFactory.responseBodyConverter
-> Retrofit.nextResponseBodyConverter(skipPast = null)
-> ... (repeats until StackOverflowError)
Passing this as skipPast breaks the cycle. Retrofit skips over our factory during the lookup and moves on to the next one in the chain.
- The lookup lands on the next matching factory, which in a typical setup is the Kotlinx Serialization (or Moshi, Gson, Jackson, etc.) converter. That becomes our delegate.
- We return a small wrapping converter. When Retrofit invokes it with the response body, we check
body.contentLength(). If it is zero, we returnnulland the JSON converter is never called. If it is non-zero, we hand the body to the delegate and let it do its normal work.
The net effect is that empty bodies produce a clean null value, and non-empty bodies flow through exactly as before.
Pitfalls to Watch Out For
The pattern is simple, but there are a few details that catch people out.
1. Your Return Type Must Be Nullable
If your factory returns null but your Retrofit interface declares a non-null return type, you will still get a crash, just farther down the pipeline. Make sure your interface reflects the reality that the body might be absent:
// Kotlin coroutines
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): User?
// Kotlin with Call
@POST("users")
fun createUser(@Body request: CreateUserRequest): Call<User?>
For Java, you rely on the fact that Response<T>.body() is already annotated @Nullable, so you just need to check for null at the call site.
2. contentLength() == 0 Is Not a Perfect Check
ResponseBody.contentLength() returns the value of the Content-Length header when it is present, and -1 when it is not. Servers using chunked transfer encoding often omit the header, so an empty chunked response returns -1, and our simple check misses it.
For most real-world REST APIs, contentLength() == 0L is enough. If you know your server uses chunked encoding for some endpoints, you can peek at the underlying stream to be more thorough. Retrofit response bodies use Okio under the hood, a small I/O library from Square that OkHttp uses for all its byte-level work. You can think of it as a friendlier, more efficient alternative to Java's InputStream and OutputStream. Calling body.source() gives us an Okio BufferedSource, which is essentially a stream with a smart buffer in front of it, and we can peek into that buffer before deciding whether to delegate:
return Converter<ResponseBody, Any?> { body ->
if (body.contentLength() == 0L) return@Converter null
val source = body.source()
source.request(1)
if (source.buffer.size == 0L) null else delegate.convert(body)
}
Here, source.request(1) asks Okio to try to read at least one byte into the buffer. If nothing arrives, the body is effectively empty. The peeked byte remains in the buffer, so the delegate can still read the full stream normally when we do call it
3. Registration Order Is Not Negotiable
I said this above, but it is worth repeating because it is a frequent source of confusion when this pattern seems not to work. Retrofit does not reorder factories by specificity. It walks them in the exact order you register them, and the first one that claims a type wins. NullOnEmptyConverterFactory must come before the JSON converter, always.
4. The Body Can Only Be Read Once
ResponseBody wraps a network stream that is consumed on read. Our contentLength() check reads header metadata, so it does not touch the stream. If you go with the defensive source.request(1) version, Okio buffers the peeked byte, so the delegate can still consume the body without missing anything. Either version is safe.
When Not to Use This Factory
The null-on-empty factory is a broad, cross-cutting fix. There are situations where a narrower solution reads better.
-
The endpoint always returns no content. If a specific endpoint is documented to never return a body, declare it as
Call<Unit>in Kotlin orCall<Void>in Java. Retrofit already treats these types specially and does not invoke a JSON converter for them. This is more expressive than reaching for the null-on-empty pattern. -
You can fix the backend. If the server is under your control and it is returning
200with an empty body when it means "no content," changing it to204is the correct HTTP behavior and eliminates the need for this workaround entirely.
Reach for NullOnEmptyConverterFactory when the same endpoint can genuinely return either a populated body or an empty one, or when many endpoints across your API might occasionally return empty bodies and you want a single, uniform fix.
Summary
The null-on-empty converter factory solves a specific but very common problem: JSON converters crash on empty response bodies when the status code is not 204 or 205. The key takeaways are:
- Retrofit already handles
204and205internally by returningnullwithout invoking the converter. The problem is limited to other status codes like200and201that arrive with an empty body. - The fix is a small
Converter.Factorythat registers before your JSON converter, delegates to the next factory in the chain, and short-circuits tonullwhen the body is empty. - The call
retrofit.nextResponseBodyConverter(this, type, annotations)fetches the delegate. The first argument tells Retrofit to skip past our own factory during that lookup, which is what prevents infinite recursion. - For the pattern to actually surface
nullto your code, your Retrofit interface must declare nullable return types. - Registration order is critical:
NullOnEmptyConverterFactorymust come first, before Gson, Moshi, or any other JSON converter factory.
With those pieces in place, your networking layer becomes resilient to a whole class of real-world server behavior that Retrofit does not handle out of the box, with less than twenty lines of code.
Top comments (0)