System Design: How to Build a Networking Library Like Retrofit or OkHttp
Networking is one of the most important infrastructure layers in a modern Android application.
Most Android developers use libraries such as Retrofit and OkHttp without thinking about everything happening behind a simple API call:
api.getUsers()
Under the hood, a production networking stack may need to handle:
- HTTP and HTTPS
- Request construction
- URL resolution
- DNS
- TCP/TLS connections
- Connection pooling
- Interceptors
- Authentication
- Serialization
- Coroutines
- Threading
- Timeouts
- Cancellation
- Retries
- HTTP caching
- Response parsing
- Error handling
- Certificate validation
- Observability
This article designs a networking library from scratch and explains the architecture behind a Retrofit/OkHttp-style system.
The goal is not to recreate Retrofit or OkHttp internally. The goal is to understand the system-design principles that make a networking library reliable, reusable, testable, and scalable.
1. What Are We Building?
We want an Android networking library that allows developers to write:
interface UserApi {
@GET("/users")
suspend fun getUsers(): List<User>
}
Then:
val users = api.getUsers()
Instead of manually writing:
val request = Request.Builder()
.url("https://example.com/users")
.get()
.build()
val response = client.newCall(request).execute()
Our library will convert a high-level API definition into an executable HTTP request.
2. Requirements
Functional Requirements
The library should support:
- GET, POST, PUT, PATCH and DELETE.
- Query parameters.
- Path parameters.
- Headers.
- Request bodies.
- Response bodies.
- JSON serialization/deserialization.
- Interceptors.
- Authentication.
- Logging.
- Coroutines.
- Request cancellation.
- Timeouts.
- Retry policies.
- HTTP caching.
- TLS/HTTPS.
- Connection pooling.
- Error handling.
- Multipart requests.
- Streaming responses.
3. Non-Functional Requirements
The networking library should be:
- Fast
- Thread-safe
- Memory efficient
- Secure
- Testable
- Modular
- Extensible
- Coroutine-friendly
- Lifecycle-aware
- Observable
- Suitable for high request concurrency
4. High-Level Architecture
The architecture can be divided into two major layers.
Android Application
|
v
API Interface Layer
|
v
Request Builder
|
v
Interceptor Chain
|
v
HTTP Client Engine
|
+-------------+-------------+
| |
v v
Connection Manager Dispatcher
| |
v v
DNS / TCP / TLS Coroutine / Threads
|
v
Server
A more complete pipeline:
flowchart LR
APP["Android App"]
APP --> API["API Interface"]
API --> FACTORY["Request Factory"]
FACTORY --> INTERCEPTOR["Interceptor Chain"]
INTERCEPTOR --> AUTH["Auth"]
INTERCEPTOR --> LOG["Logging"]
INTERCEPTOR --> RETRY["Retry"]
RETRY --> CLIENT["HTTP Client"]
CLIENT --> DISPATCHER["Dispatcher"]
DISPATCHER --> DNS["DNS"]
DNS --> TLS["TCP / TLS"]
TLS --> POOL["Connection Pool"]
POOL --> SERVER["HTTP Server"]
SERVER --> RESPONSE["HTTP Response"]
RESPONSE --> SERIALIZER["Serializer"]
SERIALIZER --> RESULT["Application Result"]
5. Core Components
A possible package structure:
networking/
│
├── api/
│ ├── GET.kt
│ ├── POST.kt
│ ├── PUT.kt
│ ├── DELETE.kt
│ └── ApiService.kt
│
├── request/
│ ├── Request.kt
│ ├── RequestBuilder.kt
│ └── RequestFactory.kt
│
├── response/
│ ├── Response.kt
│ └── ResponseParser.kt
│
├── interceptor/
│ ├── Interceptor.kt
│ ├── AuthInterceptor.kt
│ ├── LoggingInterceptor.kt
│ └── RetryInterceptor.kt
│
├── http/
│ ├── HttpClient.kt
│ ├── Dispatcher.kt
│ ├── ConnectionPool.kt
│ └── Call.kt
│
├── serialization/
│ ├── Converter.kt
│ ├── JsonConverter.kt
│ └── SerializationFactory.kt
│
├── security/
│ ├── TlsConfig.kt
│ └── CertificatePinning.kt
│
└── cache/
└── HttpCache.kt
6. API Interface Layer
The developer should be able to define APIs declaratively.
For example:
interface UserApi {
@GET("/users")
suspend fun getUsers(): List<User>
@GET("/users/{id}")
suspend fun getUser(
@Path("id") id: String
): User
@POST("/users")
suspend fun createUser(
@Body request: CreateUserRequest
): User
}
The annotations contain metadata.
Conceptually:
@GET("/users")
|
v
HTTP Method = GET
Path = /users
Return Type = List<User>
7. How Does the Library Read Annotations?
At API creation time, the library can inspect the service definition and create a request model.
For example:
data class EndpointDefinition(
val method: HttpMethod,
val path: String,
val parameters: List<ParameterDefinition>,
val returnType: Type
)
Then:
API Method
↓
EndpointDefinition
↓
RequestFactory
↓
HttpRequest
A production implementation may use generated adapters or code generation instead of reflection for performance and startup efficiency.
8. Request Model
The internal HTTP request should be independent from the API interface.
data class HttpRequest(
val method: HttpMethod,
val url: String,
val headers: Map<String, String>,
val body: RequestBody?
)
Example:
HttpRequest(
method = HttpMethod.GET,
url = "https://api.example.com/users",
headers = mapOf(
"Authorization" to "Bearer token"
),
body = null
)
This separation is important because the HTTP engine should not care whether the request came from annotations, a manual builder, or generated code.
9. Request Builder
The request builder converts high-level API metadata into an HTTP request.
class RequestBuilder(
private val baseUrl: String
) {
fun build(
endpoint: EndpointDefinition,
arguments: Map<String, Any?>
): HttpRequest {
// Resolve path parameters
// Add query parameters
// Add headers
// Serialize body
TODO()
}
}
For:
@GET("/users/{id}")
suspend fun getUser(
@Path("id") id: String
): User
The resulting URL becomes:
https://api.example.com/users/42
10. Interceptor Architecture
Interceptors are one of the most powerful concepts in a networking library.
They allow us to modify or observe requests and responses without changing the HTTP engine.
Example:
Request
|
v
AuthInterceptor
|
v
LoggingInterceptor
|
v
RetryInterceptor
|
v
HTTP Client
11. Interceptor Interface
A simplified interface:
interface Interceptor {
suspend fun intercept(
chain: Chain
): HttpResponse
interface Chain {
val request: HttpRequest
suspend fun proceed(
request: HttpRequest
): HttpResponse
}
}
An authentication interceptor:
class AuthInterceptor(
private val tokenProvider: TokenProvider
) : Interceptor {
override suspend fun intercept(
chain: Interceptor.Chain
): HttpResponse {
val request = chain.request.copy(
headers = chain.request.headers +
("Authorization" to
"Bearer ${tokenProvider.token()}")
)
return chain.proceed(request)
}
}
12. Interceptor Chain
Suppose we have:
Auth
Logging
Retry
The execution becomes:
Application
|
v
AuthInterceptor
|
v
LoggingInterceptor
|
v
RetryInterceptor
|
v
HTTP Engine
Responses travel back through the chain:
HTTP Engine
|
v
Retry
|
v
Logging
|
v
Auth
|
v
Application
This is effectively a chain-of-responsibility design.
13. Authentication
Authentication should be implemented as an interceptor rather than inside the HTTP engine.
class BearerAuthInterceptor(
private val tokenProvider: TokenProvider
) : Interceptor {
override suspend fun intercept(
chain: Interceptor.Chain
): HttpResponse {
val token = tokenProvider.getToken()
val request = chain.request.copy(
headers = chain.request.headers +
("Authorization" to "Bearer $token")
)
return chain.proceed(request)
}
}
This keeps authentication replaceable.
14. Token Refresh
A production networking library may need:
Request
|
v
401 Unauthorized
|
v
Refresh Token
|
v
Retry Original Request
But there is an important concurrency problem.
Suppose 20 requests simultaneously receive 401.
We should avoid:
20 requests
↓
20 token refresh calls
Instead:
20 requests
↓
Single token refresh
↓
New token
↓
20 requests retry
A Mutex or single-flight mechanism can coordinate token refresh.
15. Serialization
The HTTP layer receives bytes.
The application wants objects.
HTTP bytes
↓
JSON
↓
Deserializer
↓
Kotlin Object
Create a converter abstraction:
interface Converter {
fun serialize(
value: Any
): ByteArray
fun <T> deserialize(
bytes: ByteArray,
type: Type
): T
}
Implementations could use:
Kotlin Serialization
Moshi
Gson
Custom binary formats
The HTTP engine should not be tightly coupled to one serializer.
16. Response Model
A useful internal response:
data class HttpResponse(
val code: Int,
val headers: Map<String, String>,
val body: ByteArray?
)
Then a response converter transforms:
HttpResponse
↓
Converter
↓
User
17. Dispatcher and Concurrency
The networking library may execute many requests concurrently.
For example:
Request A
Request B
Request C
Request D
Request E
We need bounded concurrency.
A dispatcher can control:
Maximum concurrent requests
Per-host concurrency
Queueing
Priorities
Cancellation
Conceptually:
Dispatcher
|
+------------+------------+
| | |
Worker 1 Worker 2 Worker 3
| | |
Request A Request B Request C
Unbounded concurrency can exhaust resources.
18. Kotlin Coroutines
The public API can expose:
suspend fun getUsers(): List<User>
Internally:
suspend fun execute(
request: HttpRequest
): HttpResponse
The caller can use:
viewModelScope.launch {
val users = api.getUsers()
}
Cancellation should propagate through the entire stack.
ViewModel
↓ cancel
Coroutine
↓
Call
↓
HTTP request
↓
Socket
19. Timeout Management
Networking requires different timeout categories.
Connect Timeout
Read Timeout
Write Timeout
Call Timeout
Example configuration:
data class TimeoutConfig(
val connectMillis: Long = 10_000,
val readMillis: Long = 30_000,
val writeMillis: Long = 30_000,
val callMillis: Long = 60_000
)
Different applications may need different values.
20. Retry Strategy
Not every failure should be retried.
Potential retry candidates:
Connection timeout
Temporary network failure
HTTP 503
HTTP 502
HTTP 504
Potential non-retry cases:
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
Validation error
A retry policy can be abstracted:
interface RetryPolicy {
fun shouldRetry(
attempt: Int,
error: Throwable?
): Boolean
fun delayMillis(
attempt: Int
): Long
}
Exponential backoff:
Attempt 1 → 250 ms
Attempt 2 → 500 ms
Attempt 3 → 1000 ms
Production systems should also use jitter.
21. Idempotency Matters
Blindly retrying every HTTP request is dangerous.
For example:
POST /payments
If the server processes the request but the client loses the response, retrying may create a duplicate operation.
Therefore retry behavior should consider:
HTTP method
Idempotency
Server semantics
Request headers
Application policy
For sensitive operations, idempotency keys can help:
Idempotency-Key: abc-123
22. DNS
Before connecting to a server:
api.example.com
↓
DNS
↓
IP address
A networking stack needs to account for DNS resolution and caching.
Conceptually:
interface DnsResolver {
suspend fun resolve(
hostname: String
): List<InetAddress>
}
A custom resolver can support advanced use cases, but the default system resolver should generally be preferred unless there is a specific requirement.
23. TCP and TLS
For HTTPS:
Application
↓
HTTP
↓
TLS
↓
TCP
↓
Internet
↓
Server
TLS provides:
- Encryption
- Server authentication
- Integrity
The networking layer should rely on the platform's trusted TLS implementation rather than implementing cryptography itself.
24. Certificate Pinning
Certificate pinning can restrict which certificates or public keys are trusted for a particular host.
Conceptually:
Request
↓
TLS handshake
↓
Server certificate
↓
Pinned identity verification
↓
Connection accepted/rejected
Pinning must be operated carefully because certificate rotation can otherwise break connectivity.
25. Connection Pooling
Creating a new TCP/TLS connection for every request is expensive.
Without pooling:
Request A → TCP → TLS → HTTP → Close
Request B → TCP → TLS → HTTP → Close
With pooling:
TCP
↓
TLS
↓
Connection Pool
├── Request A
├── Request B
└── Request C
Benefits:
- Lower latency
- Less CPU work
- Fewer TLS handshakes
- Better throughput
26. HTTP/2 and HTTP/3
Modern networking libraries should be designed so the transport layer can evolve.
HTTP/2 provides:
Multiplexing
Header compression
Stream prioritization
HTTP/3 uses:
QUIC
UDP
The high-level API should not care which transport is used.
API
↓
HTTP abstraction
↓
Transport
├── HTTP/1.1
├── HTTP/2
└── HTTP/3
27. HTTP Caching
Caching can reduce network traffic.
Important headers include:
Cache-Control
ETag
Last-Modified
Expires
If-None-Match
If-Modified-Since
Example:
First request
↓
200 OK
ETag: abc123
Later:
If-None-Match: abc123
Server:
304 Not Modified
The client can reuse its cached representation.
28. Error Handling
Don't expose only:
Exception
A useful error model distinguishes categories.
sealed interface NetworkError {
data object Timeout : NetworkError
data object NoConnection : NetworkError
data class Http(
val code: Int
) : NetworkError
data object Serialization : NetworkError
data object Cancellation : NetworkError
data class Unknown(
val cause: Throwable
) : NetworkError
}
This makes error handling much easier at the application layer.
29. Logging
Logging should happen through an interceptor.
Example:
--> GET /users
Authorization: Bearer ***
<-- 200 /users
Duration: 142ms
Never log:
Passwords
Access tokens
Refresh tokens
Sensitive personal information
A good logging system should support:
NONE
BASIC
HEADERS
BODY
and redact sensitive headers.
30. Request and Response Compression
For large payloads:
gzip
brotli
can reduce bandwidth.
The transport layer can negotiate compression using HTTP headers.
The application API should not need to know whether compression was used.
31. Streaming
Not every response should be loaded into memory.
For large files:
HTTP response
↓
Stream
↓
File
A response abstraction could expose:
interface ResponseBody {
suspend fun read(): ByteArray
suspend fun stream(): InputStream
}
Streaming is important for:
- Large downloads
- Media
- Documents
- Backup files
32. Multipart Upload
A networking library should support:
multipart/form-data
For example:
POST /profile
--------------------------
name = Padmakar
avatar = profile.jpg
--------------------------
The request body can be modeled as:
sealed interface RequestBody {
data class Json(
val bytes: ByteArray
) : RequestBody
data class Multipart(
val parts: List<Part>
) : RequestBody
data class Stream(
val source: InputStream
) : RequestBody
}
33. Request Cancellation
Consider:
Search screen
↓
User types "android"
↓
Request A
Then:
User types "android networking"
↓
Request B
Request A may no longer be useful.
Cancel it:
Request A → Cancel
Request B → Execute
This reduces unnecessary work.
34. Observability
A production networking library should expose metrics.
Useful metrics:
Request count
Success rate
Error rate
Average latency
P95 latency
P99 latency
Timeout count
Retry count
DNS duration
TLS duration
Response size
Cache hit rate
Cancellation count
A useful request timeline:
Request
|
+-- DNS: 12ms
|
+-- Connect: 20ms
|
+-- TLS: 31ms
|
+-- Server: 70ms
|
+-- Download: 15ms
|
+-- Total: 148ms
35. Complete Architecture
flowchart TB
APP["Android Application"]
APP --> API["Retrofit-like API Layer"]
API --> PARSER["Annotation / Contract Parser"]
PARSER --> FACTORY["Request Factory"]
FACTORY --> INTERCEPTORS["Interceptor Chain"]
INTERCEPTORS --> AUTH["Authentication"]
INTERCEPTORS --> LOG["Logging"]
INTERCEPTORS --> RETRY["Retry Policy"]
INTERCEPTORS --> CACHE["HTTP Cache"]
RETRY --> DISPATCHER["Dispatcher"]
DISPATCHER --> HTTP["HTTP Client"]
HTTP --> DNS["DNS"]
DNS --> CONNECTION["Connection Manager"]
CONNECTION --> TLS["TLS"]
TLS --> POOL["Connection Pool"]
POOL --> SERVER["Server"]
SERVER --> POOL
POOL --> RESPONSE["Response"]
RESPONSE --> CONVERTER["Serialization"]
CONVERTER --> RESULT["Result"]
RESULT --> APP
36. Example End-to-End Request
Consider:
api.getUser("42")
The complete journey is:
1. API method invoked
2. Endpoint metadata resolved
3. Path parameter substituted
4. URL created
5. Headers added
6. Request body serialized
7. Interceptors executed
8. Authentication added
9. Retry policy configured
10. Dispatcher schedules request
11. DNS resolves host
12. Connection pool checked
13. TCP connection established if necessary
14. TLS handshake performed
15. HTTP request sent
16. Server processes request
17. HTTP response received
18. Response validated
19. Response body decoded
20. JSON deserialized
21. Kotlin object returned
That is what a simple:
api.getUser("42")
can represent internally.
37. Retrofit vs OkHttp Responsibilities
Understanding their different responsibilities is important.
| Layer | Responsibility |
|---|---|
| Retrofit-style layer | API declarations, annotations, request adapters, converters |
| OkHttp-style layer | HTTP requests, connections, interceptors, TLS, pooling, transport |
| Serialization layer | JSON/object conversion |
| Application layer | Business logic and UI state |
A useful mental model is:
Retrofit
↓
High-level API abstraction
OkHttp
↓
Low-level HTTP engine
They solve related but different problems.
38. Design Patterns Used
This architecture combines several classic patterns.
Factory Pattern
API definition
↓
Request Factory
Chain of Responsibility
Interceptor → Interceptor → Interceptor
Adapter Pattern
Converts:
API method
↓
HTTP request
Strategy Pattern
Used for:
RetryPolicy
CachePolicy
Serialization
Builder Pattern
Used for:
HttpRequest.Builder
Dependency Inversion
The core depends on interfaces:
interface HttpClient
interface Converter
interface Interceptor
interface RetryPolicy
interface DnsResolver
39. Testing Strategy
Networking libraries require extensive testing.
Unit Tests
Test:
URL generation
Path parameters
Query parameters
Headers
Serialization
Retry policy
Cache policy
Interceptor order
Error mapping
Integration Tests
Test:
Real HTTP server
TLS
Connection pooling
Timeouts
Redirects
Caching
Multipart
Streaming
Cancellation Tests
Verify:
Coroutine cancelled
↓
HTTP call cancelled
↓
Resources released
Concurrency Tests
Run:
1000 concurrent requests
and verify:
No race conditions
No leaked calls
No invalid shared state
40. Production Concerns
A production networking library should additionally consider:
Security
HTTPS
TLS validation
Certificate pinning where justified
Credential redaction
Secure token handling
Performance
Connection pooling
HTTP/2
Compression
Bounded concurrency
Streaming
Efficient serialization
Reliability
Timeouts
Retries
Backoff
Circuit breaking at appropriate higher layers
Graceful cancellation
Observability
Metrics
Tracing
Structured logs
Request IDs
41. Learning Path
If you want to implement your own networking library, don't start with the complete architecture.
Build it in stages.
Phase 1 — HTTP Client
Implement:
GET
POST
Headers
Response
Phase 2 — Request Builder
Add:
Base URL
Path parameters
Query parameters
Request body
Phase 3 — Serialization
Add:
JSON → Kotlin
Kotlin → JSON
Phase 4 — Coroutines
Add:
suspend functions
Cancellation
Dispatcher
Phase 5 — Interceptors
Implement:
Auth
Logging
Retry
Phase 6 — Connection Management
Study:
DNS
TCP
TLS
Connection Pool
HTTP/2
Phase 7 — Caching
Implement:
HTTP cache
ETag
Cache-Control
304 responses
Phase 8 — Production Features
Add:
Timeouts
Streaming
Multipart
Metrics
Tracing
Certificate pinning
Request prioritization
42. Final Mental Model
The easiest way to remember the architecture is:
Android App
|
v
API Interface
|
v
Request Factory
|
v
Interceptor Chain
|
+---------+---------+
| | |
Auth Logging Retry
| | |
+---------+---------+
|
v
Dispatcher
|
v
HTTP Client
|
+----------+----------+
| | |
DNS TLS Connection Pool
| | |
+----------+----------+
|
v
Server
|
v
Response
|
v
Serializer
|
v
Kotlin Model
|
v
Android App
A networking library is therefore much more than an HTTP wrapper.
It is a combination of:
API abstraction
+
Request construction
+
Interceptor pipeline
+
Concurrency
+
HTTP transport
+
Connection management
+
Security
+
Serialization
+
Caching
+
Error handling
+
Observability
Understanding these layers gives you a much stronger foundation for Android system design interviews and for designing networking infrastructure in real applications.
Conclusion
Retrofit and OkHttp make networking look simple:
api.getUsers()
But behind that line is an entire distributed-system interaction involving the Android runtime, concurrency, DNS, TCP, TLS, HTTP, connection pooling, serialization, caching, retries, and error handling.
The most important system-design lesson is to separate responsibilities.
Keep:
API abstraction
separate from:
HTTP transport
and keep:
serialization
authentication
retry
logging
caching
as replaceable components.
That gives the library a modular architecture that can evolve without forcing application code to change.
Author
Padmakar Garg
Android Developer focused on Kotlin, Jetpack Compose, Android Architecture, System Design, and Kotlin Multiplatform.
GitHub: https://github.com/gargpadmakar
Top comments (0)