Building Resume Instant Search

[Editor’s Note: This post is a companion piece to a recent @IndeedEng talk. Slides and video are available.]

Our resume instant search feature allows employers to see search results as they type, helping them efficiently connect with job seekers. Google found that Instant saved users 2-5 seconds per search. To prevent undesired search results for partially-typed queries, we also had to implement an auto-complete feature that predicts a user’s query before they finish typing it. Both of these features must be lightning-fast in order to be effective.

The combination of the instant and auto-complete features allows us to deliver a streamlined search to our users. We know from testing that the faster we deliver results to users, the more time they spend doing what matters — reading resumes and contacting applicants.

Building Instant Search

One of our goals for the architecture of Instant Search was to build a clean, modular JavaScript application. Our use of the Google Closure framework helped us achieve this goal. The Google Closure tools include a dependency manager, a feature-rich JavaScript library, a JavaScript compiler, and templates that compile to both Java and JavaScript. We used these tools and implemented an event-driven architecture, resulting in a modular, easy-to-maintain application.

In event-driven models, program flow is based on events such as a mouse click or a key press. A common example in web development is the onclick handler, which allows developers to capture the click event on a DOM element. The element being clicked has no knowledge of the code that runs as a result of the click. It fires an event to signal it has been clicked, and the browser dispatches that event to all components that have registered to receive it.

Instant Search has four components:

  • AutoComplete – offers search query completions based on what the user has typed.
  • Instant – retrieves and renders resume search results.
  • Preview – retrieves and renders a preview of the resume on mouseover.
  • Result Page – the main dispatcher; the only component aware of the other components.

Each component fires off events as they occur without knowing anything about the other components on the page. For example, AutoComplete will fire an event when the search completion changes, and that event is handled by the Instant component, which retrieves results. The Result Page component listens for all events and dispatches them to the registered components.

Here is example code that shows the Result Page component listening for a set of events from the AutoComplete component:

// Attach events for Query AutoComplete
goog.events.listen(this.queryAutocomplete_,
  indeed.AutoComplete.EventType.QUERY_TYPE,
  this.handleQueryChange_, false, this);
goog.events.listen(this.queryAutocomplete_,
  indeed.AutoComplete.EventType.QUERY_CHANGE,
  this.handleQueryChange_, false, this);
goog.events.listen(this.queryAutocomplete_,
  indeed.AutoComplete.EventType.ENTER,
  this.handleQueryEnter_, false, this);
goog.events.listen(this.queryAutocomplete_,
  indeed.AutoComplete.EventType.TAB,
  this.handleQueryTab_, false, this);
goog.events.listen(this.queryAutocomplete_,
  [indeed.AutoComplete.EventType.SELECT,
    indeed.AutoComplete.EventType.NEW_SUGGESTIONS],
  this.handleQuerySelect_, false, this);
goog.events.listen(this.queryAutocomplete_,
  indeed.AutoComplete.EventType.FOCUSIN,
  this.handleQueryFocusIn_, false, this);
goog.events.listen(this.queryAutocomplete_,
  indeed.AutoComplete.EventType.FOCUSOUT,
  this.handleQueryFocusOut_, false, this);

The function handleQueryChange_ passes the text of the query to the Instant component:

/**
 * Handles changes to the query by notifying Instant
 * @param {indeed.events.TextEvent} e Change event.
 * @private
 */
indeed.Search.prototype.handleQueryChange_ = function(e) {
  if (this.instant_) {
    this.instant_.newQuery(e.text);
  }
};

All other components on the page act in a similar manner — Instant fires an event when it has new results, and Preview fires an event when it displays a resume. The architecture of the application ends up looking like this:

Figure 1: Result Page is composed of high-level components, including AutoComplete and Instant, which in turn are composed of lower-level handlers and renderers.

Figure 2: How AutoComplete handles events from its sub-components

This event-driven approach allows us to add new components to the search page without having to modify the components that were already on the page. Since the components have no knowledge of the Result Page, we have been able to package them into a library for use in other projects.

Navigation and Search Engines

If we just used JavaScript-triggered requests to populate search results and did not change the URL, our users would never be able to save or share specific search result pages, and search engines like Google could not crawl these pages for indexing.

In our initial implementation of Instant Search, we updated the URL fragment on every new search. If a user came to http://www.indeed.com/resumes and did a search for “java” in Austin, we updated the URL to http://www.indeed.com/resumes#!q=java&l=Austin. For comparison, before we implemented Instant Search, our search URLs used a more traditional query string approach (still supported): http://www.indeed.com/resumes?q=java&l=Austin.

We used #! instead of simply # in order to work with Google’s Making AJAX Applications Crawlable guidelines, which specify a mapping from #! URL fragments to query string parameters so that crawlers can make requests with information stored in the URL fragment.

A drawback of this approach is initial page load performance. Since the data in URL fragments is not sent to the server, the browser has to do a full round-trip (request/response) to the server before processing the fragment, then an additional round-trip to get the search results. In contrast, using the traditional query string approach allows results to come back in a single round-trip.

To improve performance for users coming directly to a search results page from an external site, we used the HTML5 history API supported by most modern browsers. This API enables modifying the whole URL via JavaScript rather than just the URL fragment. We now use this API to update the browser URL to include the search terms in the path (e.g. http://www.indeed.com/resumes/java/in-Austin). These URLs can then be shared and published widely, and users clicking on the resulting links receive search results in a single round-trip.

Templates and Rendering

Dynamically updating search results in the browser with JavaScript provides a superior user experience, but we also need to be able to render identical results on the server. We could have just developed two versions of our templates, one for server-side and one for client-side, but this approach leads to maintainability challenges. It would be annoying and error-prone to keep two versions, in two different template languages, in perfect sync. We could have generated HTML on the server-side only, and send the HTML back in response to the Instant request, but this approach results in a larger response and adds server load. Instead, we opted to use the same template on both the client and server. Technologies like Node.js, mustache, and Google Closure Templates are making this technique viable and more common.

We chose Google Closure Templates to enable a single template that works both on the server (Java-based) and the client (JavaScript-based). Here is an example template:

/**
 * Renders a single block of refinements on the result page
 * @param title Title of the refinement
 * @param refinements List of refinements
 *
 * @private
 */
{template .refinementBlock}
  {if length($refinements) > 0}
    <p class="refine_title">{$title}</p>
    <ul class="refine_by">
    {foreach $refinement in $refinements}
      <li>
        <a class="instl" href="?{$refinement['addParams']}">
          {$refinement['text']}
        </a>
        <span class="refine_count">{$refinement['count']}</span>
      </li>
    {/foreach}
    </ul>
  {/if}
{/template}

The Closure template compiler generates JavaScript that integrates well into the client-side rendering of our search result pages. We also use the templates in our server-side Java code, delivering results quickly for initial requests and enabling subsequent searches for users and crawlers that are not using JavaScript.

No Compromises

Our event-driven architecture has allowed us to build a maintainable library of components that can be used in multiple projects. We have used URL fragments and the HTML5 history API to keep navigation, link sharing, and search engine discovery simple. Closure Templates have helped us avoid unnecessary complexity and duplication. We have built a great resume search experience for our users without compromising on maintainability, reuse, and performance.

At Indeed, we focus on making the job seeker and employer experiences as fast and simple as possible. If these kind of challenges sound interesting to you, check out our open positions.