Opening

Your server sends JSON. Your app needs Kotlin objects. Something has to convert between the two. You probably set up Moshi or Gson or kotlinx.serialization once, and you never thought about it again. It just works. But there is a real cost hiding inside that conversion, and for many apps, serialization is a bigger performance bottleneck than the network itself. Today we are going to look inside the serialization process. We will understand why reflection-based serialization is dramatically slower than code generation. We will count the objects and string allocations a typical JSON response creates. We will compare tree parsing to streaming, JSON to Protobuf, and Moshi to kotlinx.serialization. By the end, you will know exactly where serialization fits in your performance budget — and when it is time to optimize.

What Serialization Actually Is

Let us start with the basics. Serialization is converting structured data — objects, with fields and types — into a flat format that can be stored or transmitted. Deserialization is the reverse: taking that flat format and rebuilding the objects. When your server sends a JSON response, it serializes its internal data structures into a JSON string. When your app receives that string, it deserializes it into Kotlin data classes. This might sound trivial, but think about what needs to happen. The library has to parse the JSON text character by character. It has to match JSON keys to Kotlin property names. It has to convert JSON types to Kotlin types — strings to Strings, numbers to Ints or Doubles, arrays to Lists. It has to handle nullability, default values, nested objects. And it has to create all the objects that represent your data. Every single one of these steps costs CPU time and memory allocations.

Reflection vs Code Generation

Here is the most important distinction in Android serialization: reflection versus code generation. Gson uses reflection by default. When it needs to deserialize a User class, it uses the Java reflection API to discover the class's fields at runtime. It calls Class.getDeclaredFields, iterates over them, sets them accessible, and assigns values using Field.set. This works, but it is slow. Reflection is slow for several reasons. The JVM has to do security checks. It cannot optimize reflective calls the way it optimizes direct calls. And it creates a lot of intermediate objects. Moshi can work either way. With Moshi's reflection adapter, it uses Kotlin reflection — which is even heavier than Java reflection because it loads the entire kotlin-reflect library. But Moshi also supports code generation through annotation processing. When you annotate your class with JsonClass and set generateAdapter to true, Moshi generates a custom adapter class at compile time. This adapter has hardcoded field names, direct constructor calls, no reflection at all. The performance difference is massive — often 3 to 10 times faster.

How Moshi Adapters Work

Let me explain what a generated Moshi adapter actually does, because understanding it reveals why it is so much faster. When you write a data class User with fields name and age and annotate it with JsonClass generateAdapter true, the annotation processor generates a class called UserJsonAdapter. This adapter has a fromJson method that works like hand-written parsing code. It creates a JsonReader, reads the opening brace, then enters a loop. For each key in the JSON, it calls a selectName method against a pre-built options object — this is essentially a hash lookup, not a string comparison. When it finds "name," it reads the string value directly. When it finds "age," it reads the integer value directly. Then it calls the User constructor with those values. No reflection. No field scanning. No dynamic type resolution. Just direct, compiled code. The selectName trick with pre-built Options is particularly clever — it avoids allocating new strings for JSON keys entirely. Moshi pre-computes the byte sequences for your field names and matches against the raw byte stream. This saves both time and memory.

kotlinx.serialization: Compile-Time Magic

kotlinx.serialization takes the code generation approach even further. It is a Kotlin compiler plugin, not just an annotation processor. When you annotate a class with Serializable, the Kotlin compiler itself generates the serializer as part of compilation. This means it has access to information that annotation processors do not — like default values, sealed class hierarchies, and inline classes. The generated serializer is tightly integrated with the Kotlin type system. The generated code is extremely efficient. For a simple data class, the serializer reads JSON tokens sequentially and assigns them to a packed bit mask that tracks which fields have been seen. Once all fields are read, it calls the constructor directly. If a field is missing and has a default value, the serializer knows this at compile time and uses the default — no reflection needed to discover defaults at runtime. In benchmarks, kotlinx.serialization is typically the fastest option for Kotlin data classes, slightly ahead of Moshi codegen and dramatically ahead of Gson or Moshi reflection.

The Hidden Cost: Object Allocations

Now let us talk about something most developers never consider — the sheer number of objects that serialization creates. Imagine you receive a JSON response with a list of 50 users. Each user has a name, an email, an address object with four fields, and a list of three phone numbers. That is roughly 50 User objects, 50 Address objects, 150 PhoneNumber objects, and hundreds of String objects for all the field values. That is easily 500 to 1000 object allocations from a single API response. Each allocation has a cost. The runtime has to find space on the heap. The garbage collector has to track the object. When the GC runs, it has to trace through all these objects to determine which are still alive. On Android, garbage collection pauses can cause frame drops. If you are parsing a large response on the main thread — please do not — those GC pauses are directly visible as jank. Even on a background thread, excessive allocations put pressure on the garbage collector, which can affect the main thread indirectly.

Streaming vs Tree Parsing

There are two fundamental approaches to parsing JSON: tree parsing and streaming. Tree parsing — what Gson does by default with its JsonElement type — reads the entire JSON into an in-memory tree structure. Every JSON object becomes a JsonObject. Every JSON array becomes a JsonArray. Every value becomes a JsonPrimitive. Then your deserializer walks this tree to build your Kotlin objects. The problem? You have effectively doubled the memory usage. The tree is a complete copy of the data, plus all your Kotlin objects. For a 100KB JSON response, you might use 300KB or more of heap memory. Streaming parsers — like Moshi's JsonReader or Gson's lower-level JsonReader — work differently. They process the JSON token by token, never building an intermediate tree. They read the opening brace, then the first key, then the first value, and immediately pass that to your adapter. At any given moment, only the current token is in memory. This dramatically reduces memory pressure. For large responses, streaming is not just faster — it is necessary. A 1MB JSON response parsed as a tree can easily consume 3 to 5MB of heap. Streaming keeps memory usage proportional to the size of your output objects, not the size of the JSON.

Protobuf vs JSON: Binary Wins

Let us talk about an alternative to JSON entirely: Protocol Buffers — protobuf. JSON is text-based. Keys are repeated in every object. Numbers are encoded as strings. Whitespace and formatting add bytes. Protobuf is binary. Keys are replaced with small integer field tags. Numbers are encoded as compact binary values. There is no whitespace. A typical JSON payload might be 100KB. The same data in protobuf might be 30 to 50KB. That is 50 to 70 percent smaller. Smaller payloads mean less data to download — directly saving time on mobile networks. But the benefits go beyond size. Protobuf parsing is also faster because the parser does not have to handle string matching for field names, does not have to convert string-encoded numbers, and can read fixed-width values directly from the byte stream. In benchmarks, protobuf deserialization is typically 5 to 10 times faster than JSON. Wire, Square's protobuf library for Kotlin, integrates beautifully with OkHttp and Retrofit. If you control both the client and server, protobuf is worth serious consideration for any endpoint where performance matters.

When Serialization Becomes Your Bottleneck

How do you know if serialization is a bottleneck in your app? The answer is profiling, but let me tell you when to suspect it. If your API responses are large — more than 50KB — serialization time becomes significant. A 100KB JSON response might take 20 to 50 milliseconds to deserialize with Moshi codegen, and 100 to 200 milliseconds with Gson reflection. That is real time in your latency budget. If you are parsing on the main thread — and I have seen this in production apps — those milliseconds directly cause frame drops. Even on Dispatchers.IO, if you are parsing many responses concurrently, you can saturate the thread pool. Use Android Studio's profiler to check. Record a CPU trace while your app loads data. Look for time spent in fromJson or deserialize methods. If serialization shows up as a significant portion of your API call time, you have three levers to pull. Switch from reflection to codegen. Switch from tree parsing to streaming. Or switch from JSON to protobuf. Each one can give you a 2 to 10 times improvement.

Practical Recommendations

Let me give you concrete recommendations. First: use kotlinx.serialization or Moshi with code generation. Never use Gson for new projects — it is reflection-based by default, it does not support Kotlin nullability properly, and it is slower across the board. Second: always deserialize on a background thread. Use Dispatchers.IO or Dispatchers.Default — not Main. Third: for large responses, consider pagination. Instead of loading 500 items at once, load 20 at a time. This reduces both download time and serialization pressure. Fourth: look at your data classes. Do you really need every field the server sends? Use JsonIgnore or simply do not declare fields you do not need. Less data to parse means faster parsing. Fifth: if you have performance-critical endpoints with large payloads, benchmark protobuf. Wire makes it easy, and the performance difference can be dramatic.

The Serialization Tax in Context

Let me put serialization in context with the full network call. Remember our breakdown from the first video? DNS 50 milliseconds, TCP 50, TLS 100, server 200, download 100. That is 500 milliseconds total. Now add serialization. For a typical response with Moshi codegen, add 10 to 30 milliseconds. Barely noticeable. With Gson reflection on a larger payload? 50 to 200 milliseconds. That is 10 to 40 percent added to your total latency. For an app making 10 API calls on launch — fetching the feed, the user profile, settings, notifications — those serialization costs stack up. 10 calls times 50 milliseconds of unnecessary serialization overhead is half a second of wasted time. Just by switching from Gson to Moshi codegen, you could shave hundreds of milliseconds off your app's startup time. I have seen this in real production apps. It is not theoretical. Serialization is a tax you pay on every API call, and the library you choose determines the tax rate.

Recap and What is Next

Let us recap. Serialization converts between JSON text and Kotlin objects, and it happens on every API call. Reflection-based serialization — Gson, Moshi without codegen — is 3 to 10 times slower than code-generated approaches. Moshi with generated adapters uses clever tricks like byte-sequence matching and pre-built options to avoid allocations. kotlinx.serialization leverages the Kotlin compiler for even tighter integration. Object allocations from parsing create GC pressure — streaming parsers help by avoiding intermediate tree structures. Protobuf is 50 to 70 percent smaller and 5 to 10 times faster to parse than JSON. Always deserialize off the main thread. In our final video for this phase, we are going to tackle the biggest networking challenge of all — what happens when there is no network. Offline-first architecture, syncing strategies, and conflict resolution. See you there.