Accelerating Alternative Accommodation Comparison in a Custom Booking Engine
How we cut accommodation search latency from 12s to 200ms using strategy routing, async background jobs, and flexible caching (Cache::flexible) without sacrificing rate accuracy.

The Challenge
In custom travel software, travel consultants need to quickly compare multiple accommodation options for a single leg of a trip. The core technical challenge lies in the heterogeneity of data sources: on one hand, ultra-fast local contract inventories; on the other, external partner integrations (third-party APIs) with variable response times, strict rate limits, and outage risks.
Querying all these sources synchronously during a search blocks the UI and degrades user experience. Conversely, relying blindly on cached data introduces a critical business risk: cached rates are fine for exploring options, but they can never replace a confirmed availability before final booking.
Architecture & Data Flow
Trip Leg + Dates + Guests
│
▼
Accommodation Selection
│
┌──────┴──────┐
▼ ▼
Local Inventory External Sources
(Sync <10ms) (Async / Workers)
│ │
Direct Display Background Workers:
to Consultant Rate Update & Enrichment
│ │
└──────┬──────┘
▼
Normalized & Comparable Alternatives
│
▼
Final Re-verification Before Confirmation
Key Technical Decisions
1. Routing Searches via Strategy Pattern
Not all sources share the same performance profile or data freshness. Rather than accumulating nested if / else statements inside a single monolithic service, a search router delegates execution to specialized strategy classes based on domain criteria (dates, group size, inventory type).
Concrete strategy examples:
- LocalContractStrategy: Queries contracted inventory stored in the local database (< 10 ms, zero timeout risk) to deliver an instant response.
- ExternalAggregatorStrategy: Queries an external wholesaler via API using flexible caching or background workers to absorb latency.
- DirectConnectStrategy: Queries a partner hotel chain's PMS directly, wrapped in a fallback mechanism for resilience.
interface AccommodationSearchStrategy
{
public function supports(SearchCriteria $criteria): bool;
public function search(SearchCriteria $criteria): StrategySearchResult;
}
final readonly class AccommodationSearchRouter
{
public function __construct(
private SearchStrategySelector $strategySelector,
private AccommodationSearchCoordinator $coordinator,
) {}
public function search(SearchCriteria $criteria): SearchResult
{
$strategies = $this->strategySelector->selectFor($criteria);
return $this->coordinator->search(criteria: $criteria, strategies: $strategies);
}
}
final readonly class SearchStrategySelector
{
/**
* @param iterable<AccommodationSearchStrategy> $strategies
*/
public function __construct(private iterable $strategies) {}
/**
* @return array<AccommodationSearchStrategy>
*/
public function selectFor(SearchCriteria $criteria): array
{
$selected = [];
foreach ($this->strategies as $strategy) {
if ($strategy->supports($criteria)) {
$selected[] = $strategy;
}
}
return $selected;
}
}
final readonly class AccommodationSearchCoordinator
{
/**
* @param array<AccommodationSearchStrategy> $strategies
*/
public function search(SearchCriteria $criteria, array $strategies): SearchResult
{
$rawResults = [];
foreach ($strategies as $strategy) {
try {
$rawResults[] = $strategy->search(criteria: $criteria);
} catch (\Throwable) {
continue;
}
}
return SearchResult::fromStrategyResults(results: $rawResults);
}
}
- Why this choice? Adhering to the Open/Closed Principle decouples each third-party integration. Every strategy normalizes its raw payload into a shared DTO model (StrategySearchResult), isolating the UI from API contract changes and making every component independently testable.
2. Instant Local Display with Background Revalidation (Flexible Cache)
To guarantee zero perceived latency, results from local storage (LocalContractStrategy) return immediately. In parallel, heavy external rate fetching and updates are offloaded to background job queues.
For reading candidate options, Cache::flexible() implements the stale-while-revalidate pattern: the application serves an existing cached entry instantly (even if past its nominal freshness window) while triggering a background task to refresh the data.
$snapshot = Cache::flexible(
$cacheKey,
[20, 120], // Fresh window (20s), then tolerance window (120s total)
fn () => [
'alternatives' => $inventoryReader->findAlternatives($criteria),
'observed_at' => now()->toIso8601String(),
],
);
$observedAt = CarbonImmutable::parse($snapshot['observed_at']);
$isStale = $observedAt->lt(now()->subSeconds(20));
return AlternativeAccommodationList::fromSnapshot(
alternatives: $snapshot['alternatives'],
observedAt: $observedAt,
requiresRefresh: $isStale,
);
3. Isolating Integrations to Prevent Cascading Failures
A slow or unresponsive partner API must never degrade the entire booking workflow. The architecture enforces multiple protective layers: strict timeouts, rate limiting, and a circuit breaker mechanism that temporarily suspends requests to failing providers.
final class ResilientAccommodationAdapter
{
public function __construct(
private AccommodationProvider $provider,
private ProviderCircuitBreaker $circuitBreaker,
private ProviderRateLimiter $rateLimiter,
) {}
public function search(SearchCriteria $criteria): ProviderSearchResult
{
return $this->circuitBreaker->run(
providerKey: $this->provider->key(),
operation: function () use ($criteria): ProviderSearchResult {
$this->rateLimiter->ensureCapacity($this->provider->key());
return $this->provider->search(
criteria: $criteria,
connectTimeoutSeconds: 2,
timeoutSeconds: 5,
);
},
);
}
}
namespace App\Infrastructure\Resilience;
use Illuminate\Support\Facades\Cache;
use Throwable;
final class ProviderCircuitBreaker
{
public function __construct(
private int $failureThreshold = 5, // Nombre d'échecs avant ouverture
private int $cooldownSeconds = 60, // Temps de blocage avant réessai
) {}
public function run(string $providerKey, callable $operation): mixed
{
$stateKey = "circuit_breaker:{$providerKey}:state";
$failuresKey = "circuit_breaker:{$providerKey}:failures";
// 1. Si le circuit est ouvert, on bloque net (fail fast)
if (Cache::get($stateKey) === 'OPEN') {
throw new \RuntimeException("Circuit breaker is OPEN for [{$providerKey}]. Request blocked.");
}
try {
// 2. Exécution de l'appel cible
$result = $operation();
// Succès : réinitialisation des compteurs
Cache::forget($failuresKey);
Cache::forget($stateKey);
return $result;
} catch (Throwable $e) {
// 3. Échec : incrémentation du compteur d'erreurs
$failures = Cache::increment($failuresKey);
Cache::put($failuresKey, $failures, now()->addMinutes(10));
// Si le seuil est atteint, on ouvre le circuit pour la durée du cooldown
if ($failures >= $this->failureThreshold) {
Cache::put($stateKey, 'OPEN', now()->addSeconds($this->cooldownSeconds));
}
throw $e;
}
}
}Key Takeaways
- Performance & Responsiveness: Immediate UI rendering driven by synchronous local inventory, progressively enriched by background asynchronous workers.
- Operational Resilience: Total isolation from third-party outages via Circuit Breakers and Rate Limiters, preventing cascading delays across the system.
- Business Safety: A clear separation between rapid option exploration (indicative rates) and critical real-time confirmation before financial commitment.
Are unstable external data sources slowing down your booking tools or impacting team productivity? I can help audit your architecture bottlenecks and design a more responsive, fault-tolerant integration strategy.
// TECHNOLOGY STACK