How do I implement request deduplication with Alamofire?
Request deduplication is a crucial optimization technique that prevents redundant network calls by identifying and eliminating duplicate requests. In Alamofire, you can implement this using custom interceptors, request tracking mechanisms, and caching strategies to improve application performance and reduce unnecessary network traffic.
Understanding Request Deduplication
Request deduplication works by creating unique identifiers for requests and tracking ongoing operations. When a new request matches an existing one, instead of making another network call, the system either:
- Returns the cached response from a previous identical request
- Waits for the ongoing request to complete and shares its result
- Cancels the duplicate request entirely
This is particularly useful in scenarios where users might trigger the same API call multiple times quickly, or when implementing features like search-as-you-type functionality.
Basic Request Deduplication Implementation
Here's a fundamental approach using a custom request interceptor:
import Alamofire
import Foundation
class RequestDeduplicationInterceptor: RequestInterceptor {
private var ongoingRequests: [String: DataRequest] = [:]
private let queue = DispatchQueue(label: "request.deduplication", attributes: .concurrent)
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
completion(.success(urlRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
let requestKey = createRequestKey(from: request.request!)
queue.async(flags: .barrier) {
self.ongoingRequests.removeValue(forKey: requestKey)
}
completion(.doNotRetry)
}
private func createRequestKey(from request: URLRequest) -> String {
var components: [String] = []
if let url = request.url?.absoluteString {
components.append(url)
}
components.append(request.httpMethod ?? "GET")
if let httpBody = request.httpBody,
let bodyString = String(data: httpBody, encoding: .utf8) {
components.append(bodyString)
}
return components.joined(separator: "|")
}
}
Advanced Deduplication with Response Sharing
For more sophisticated deduplication that shares responses between duplicate requests:
class AdvancedDeduplicationManager {
private var pendingRequests: [String: [((Result<Any, AFError>) -> Void)]] = [:]
private var responseCache: [String: (response: Any, timestamp: Date)] = [:]
private let cacheTimeout: TimeInterval = 300 // 5 minutes
private let queue = DispatchQueue(label: "deduplication.manager", attributes: .concurrent)
func performRequest<T: Codable>(
_ request: URLRequest,
responseType: T.Type,
completion: @escaping (Result<T, AFError>) -> Void
) {
let requestKey = createRequestKey(from: request)
queue.async(flags: .barrier) {
// Check if we have a cached response
if let cachedItem = self.responseCache[requestKey],
Date().timeIntervalSince(cachedItem.timestamp) < self.cacheTimeout,
let cachedResponse = cachedItem.response as? T {
DispatchQueue.main.async {
completion(.success(cachedResponse))
}
return
}
// Check if request is already pending
if self.pendingRequests[requestKey] != nil {
// Add callback to existing request
self.pendingRequests[requestKey]?.append { result in
if let response = try? result.get() as? T {
completion(.success(response))
} else if case .failure(let error) = result as? Result<T, AFError> {
completion(.failure(error))
}
}
return
}
// Initialize pending request array
self.pendingRequests[requestKey] = [{ result in
if let response = try? result.get() as? T {
completion(.success(response))
} else if case .failure(let error) = result as? Result<T, AFError> {
completion(.failure(error))
}
}]
// Make the actual request
AF.request(request)
.validate()
.responseDecodable(of: responseType) { [weak self] response in
self?.queue.async(flags: .barrier) {
let callbacks = self?.pendingRequests.removeValue(forKey: requestKey) ?? []
switch response.result {
case .success(let data):
// Cache successful response
self?.responseCache[requestKey] = (response: data, timestamp: Date())
// Notify all waiting callbacks
DispatchQueue.main.async {
callbacks.forEach { callback in
callback(.success(data as Any))
}
}
case .failure(let error):
// Notify all callbacks of failure
DispatchQueue.main.async {
callbacks.forEach { callback in
callback(.failure(error))
}
}
}
}
}
}
}
private func createRequestKey(from request: URLRequest) -> String {
var hasher = Hasher()
hasher.combine(request.url?.absoluteString)
hasher.combine(request.httpMethod)
if let httpBody = request.httpBody {
hasher.combine(httpBody)
}
if let headers = request.allHTTPHeaderFields {
for (key, value) in headers.sorted(by: { $0.key < $1.key }) {
hasher.combine(key)
hasher.combine(value)
}
}
return String(hasher.finalize())
}
func clearCache() {
queue.async(flags: .barrier) {
self.responseCache.removeAll()
}
}
}
Implementing Custom Session with Deduplication
Create a custom Alamofire session that automatically handles deduplication:
class DeduplicatingSession {
private let session: Session
private let deduplicationManager = AdvancedDeduplicationManager()
init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
self.session = Session(
configuration: configuration,
interceptor: RequestDeduplicationInterceptor()
)
}
func request<T: Codable>(
_ url: String,
method: HTTPMethod = .get,
parameters: Parameters? = nil,
encoding: ParameterEncoding = URLEncoding.default,
headers: HTTPHeaders? = nil,
responseType: T.Type,
completion: @escaping (Result<T, AFError>) -> Void
) {
do {
let urlRequest = try URLRequest(
url: url,
method: method,
headers: headers
)
let encodedRequest = try encoding.encode(urlRequest, with: parameters)
deduplicationManager.performRequest(
encodedRequest,
responseType: responseType,
completion: completion
)
} catch {
completion(.failure(.parameterEncodingFailed(reason: .jsonEncodingFailed(error: error))))
}
}
}
Usage Examples
Here's how to use the deduplicating session in practice:
let deduplicatingSession = DeduplicatingSession()
// Multiple rapid calls to the same endpoint
for i in 1...5 {
deduplicatingSession.request(
"https://api.example.com/users/123",
responseType: User.self
) { result in
switch result {
case .success(let user):
print("Request \(i): Received user \(user.name)")
case .failure(let error):
print("Request \(i): Failed with error \(error)")
}
}
}
// Only one actual network request will be made, but all callbacks will receive the response
Search Implementation with Deduplication
A practical example for search functionality where deduplication prevents excessive API calls:
class SearchManager {
private let deduplicatingSession = DeduplicatingSession()
private var searchWorkItem: DispatchWorkItem?
func search(query: String, completion: @escaping (Result<[SearchResult], AFError>) -> Void) {
// Cancel previous search if still pending
searchWorkItem?.cancel()
// Debounce rapid searches
searchWorkItem = DispatchWorkItem { [weak self] in
guard !query.isEmpty else {
completion(.success([]))
return
}
self?.deduplicatingSession.request(
"https://api.example.com/search",
method: .get,
parameters: ["q": query, "limit": 20],
responseType: SearchResponse.self
) { result in
switch result {
case .success(let response):
completion(.success(response.results))
case .failure(let error):
completion(.failure(error))
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: searchWorkItem!)
}
}
struct SearchResponse: Codable {
let results: [SearchResult]
}
struct SearchResult: Codable {
let id: String
let title: String
let description: String
}
Testing Request Deduplication
Here's how to test your deduplication implementation:
import XCTest
@testable import YourApp
class RequestDeduplicationTests: XCTestCase {
var deduplicationManager: AdvancedDeduplicationManager!
override func setUp() {
super.setUp()
deduplicationManager = AdvancedDeduplicationManager()
}
func testDuplicateRequestsShareResponse() {
let expectation = XCTestExpectation(description: "Both requests complete")
expectation.expectedFulfillmentCount = 2
let request = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts/1")!)
var responses: [Post] = []
// Make two identical requests
deduplicationManager.performRequest(request, responseType: Post.self) { result in
if case .success(let post) = result {
responses.append(post)
}
expectation.fulfill()
}
deduplicationManager.performRequest(request, responseType: Post.self) { result in
if case .success(let post) = result {
responses.append(post)
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 10.0)
// Both responses should be identical
XCTAssertEqual(responses.count, 2)
XCTAssertEqual(responses[0].id, responses[1].id)
}
}
struct Post: Codable, Equatable {
let id: Int
let title: String
let body: String
let userId: Int
}
Best Practices and Considerations
When implementing request deduplication with Alamofire:
Memory Management: Implement proper cleanup mechanisms to prevent memory leaks from accumulated cached responses and pending request callbacks.
Cache Invalidation: Set appropriate cache timeouts and provide methods to manually clear cached responses when needed.
Error Handling: Ensure that failed requests don't prevent future requests to the same endpoint from being executed.
Thread Safety: Use concurrent queues with barrier flags to ensure thread-safe access to shared data structures.
Request Uniqueness: Consider all relevant request parameters when creating unique keys, including headers, HTTP methods, and request bodies.
Performance Benefits
Request deduplication provides several performance advantages:
- Reduced Network Traffic: Eliminates redundant API calls, saving bandwidth and reducing server load
- Improved Response Times: Cached responses are delivered immediately without network latency
- Better User Experience: Prevents UI flickering and inconsistent states caused by multiple simultaneous requests
- Resource Conservation: Reduces CPU and memory usage associated with redundant network operations
By implementing request deduplication in your Alamofire-based applications, you can significantly improve performance and create a more efficient networking layer. The techniques shown above provide a solid foundation that can be adapted to your specific use cases and requirements.