Skip to article
← All blog posts

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:

  1. Take a small group of chunks from the queue.
  2. Start their tasks together.
  3. Save each successful chunk when it finishes.
  4. Record failures, then continue with the next group.
Figure 1 · Illustrative chunk scheduling

Sequential example

Chunk 1 · process + save
Chunk 2 · process + save
Next chunk

Each chunk waits.

Concurrent example

Chunk 1 · starts now
Chunk 2 · starts now
Next group waits

Successful chunks save independently.

This example overlaps two chunks. It does not show a production concurrency limit. Box sizes do not represent duration.

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:

Figure 2 · Illustrative split of 200 hotel IDs
Chunk 1
12
Chunk 2
24
Chunk 3
48
Chunk 4
96
Chunk 5
20
12 + 24 + 48 + 96 + 20 = 200 requested IDs. These values are examples. The last chunk contains only the remaining IDs; bar lengths do not represent time.

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.

Figure 3 · Illustrative count update

Write a count snapshot

  1. Chunk A saves 4 results and counts 4. Its session update waits.
  2. Chunk B saves 6 results and counts 10.
  3. Chunk B writes 10 to the session.
  4. Chunk A writes its older count: 4.

10 stored · session says 4

Increment the count

  1. Chunk A saves 4 results.
  2. Chunk B saves 6 results.
  3. Each successful task increments the total by its saved count.

0 + 4 + 6 = 10

The increment order does not change the total.

These are example values and a possible task order. An atomic increment prevents concurrent increments from overwriting each other.

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:

  1. Session response time: from the request to the returned session ID.
  2. First-result time: from the request to the first result displayed by the client.
  3. 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.

  1. MDN: Promise.all()
  2. MDN: Promise.allSettled()
  3. MongoDB: $inc and atomic updates within one document