Backend engineering · A generalized case study
Speeding Up Hotel Search with Concurrent Chunks
Independent search requests can overlap, but their results still need consistent handling. Faster scheduling and correct shared state belong in the same design.
This article draws on my experience with hotel search. The examples generalize the techniques and omit company-specific configuration, supplier behavior, and internal names.
The original benchmark logs are no longer available. This article focuses on the approach rather than a numeric performance claim.
1. Separate the response from the search work
A hotel search can return a session ID while availability is fetched in the background. The client then polls that session for results.
A chunk is a group of hotel IDs processed together. Processing a chunk can include availability requests, enrichment, price calculations, and saving results.
Calls to independent providers can run concurrently:
const results = await Promise.all(
providers.map((provider) => provider.search(chunk)),
);
Illustrative code. Provider failure handling is discussed below.
Concurrency inside a chunk does not automatically overlap separate chunks. A loop that awaits each complete chunk still makes later chunks wait.
2. Bound the work that runs together
When chunks are independent, some can overlap. Each chunk should keep its request, processing, and save within its own task.
A simple illustrative scheduler can:
- Take a small group of chunks from the queue.
- Start their tasks together.
- Save each successful chunk when it finishes.
- Record failures, then continue with the next group.
Sequential example
Each chunk waits.
Concurrent example
Successful chunks save independently.
A fixed group waits for its slowest task before the next group starts. A worker pool can instead take another chunk whenever a worker becomes free.
The appropriate limit depends on provider quotas, connection use, and database capacity. A limit within one search also needs to be assessed against the combined demand from simultaneous searches.
3. Use smaller early chunks when they help
Small early chunks can make some results available before the rest of the search finishes. Larger later chunks can reduce the number of requests.
A simple example doubles the requested size after each chunk. With 200 input IDs, it might divide the work as follows:
Call this progressive chunk sizing when sizes follow fixed rules. A runtime-adaptive approach would need feedback from measurements such as latency or errors.
Requested IDs and returned hotels are different counts. A provider may return no availability for some IDs.
Chunk order also does not guarantee completion order. With concurrent tasks, a later chunk may finish before an earlier one.
4. Update shared counts atomically
Concurrent tasks can safely save separate results while still conflicting over a shared total.
Consider a common pattern: save a chunk, count stored results, then write that count to the session. A delayed write can replace a newer count with an older snapshot.
This is a lost-update race. The example below shows how it can happen.
Write a count snapshot
- Chunk A saves 4 results and counts 4. Its session update waits.
- Chunk B saves 6 results and counts 10.
- Chunk B writes 10 to the session.
- Chunk A writes its older count: 4.
10 stored · session says 4
Increment the count
- Chunk A saves 4 results.
- Chunk B saves 6 results.
- Each successful task increments the total by its saved count.
0 + 4 + 6 = 10
The increment order does not change the total.
An atomic increment changes a stored counter as one database operation.
MongoDB's $inc
provides this guarantee within one document.
await counters.updateOne(
{ id: searchId },
{ $inc: { savedCount: savedResults.length } },
);
Illustrative names. Count saved results, rather than requested IDs.
Result inserts and a counter update are still separate operations unless the design makes them transactional. A failure between them can cause a mismatch.
Retries need attention too: repeating an increment can count the same work twice. Consider duplicate protection and a final comparison against stored results when choosing the consistency model.
5. Preserve dependencies during enrichment
Some processing steps need earlier results. Others can start together.
For illustration, static hotel details and display settings can be fetched independently:
const [details, displaySettings] = await Promise.all([
fetchHotelDetails(hotelIds),
fetchDisplaySettings(),
]);
Keep dependent calculations in order. If a price calculation reads the previous step's output, running those steps together changes the behavior.
Draw the dependencies before adding concurrency. Start independent work together, then combine its results where needed.
6. Decide what happens when a task fails
Choose error handling to match the required result.
Promise.all
rejects when an input rejects. It does not cancel the other operations.
Use it when the next step requires every result.
Promise.allSettled
waits for every input to settle and reports each outcome.
Inspect those outcomes and record failures explicitly.
An empty successful result and a failed request carry different information. Preserve that distinction when reporting completeness.
Neither method creates a timeout. Saving completed chunks independently also requires a save within each chunk's task; a promise combinator alone does not provide that behavior.
7. Compare equivalent work
Keep three measurements separate:
- Session response time: from the request to the returned session ID.
- First-result time: from the request to the first result displayed by the client.
- Completion time: from the request to all scheduled work finishing.
Backend persistence and browser display are separate events. Polling adds another wait between a saved result and the client showing it.
Define where each timer starts and stops. A timer for enrichment should not include time spent waiting for an availability request.
Use equivalent inputs, provider coverage, cache conditions, and error handling when comparing implementations. Record both the duration and how much useful work completed.
Test simultaneous searches separately. A request-level limit does not establish how the service behaves under combined load.
The design choices belong together
Chunk sizing controls how work is divided. Scheduling controls which independent tasks overlap. Persistence determines when results become available and how shared state stays consistent.
Assess all three together. The goal is useful results with explicit limits, failure handling, and a count users can trust.
References
The code, numbers, and diagrams are generalized teaching examples rather than a copy of a production implementation.