Guides / Building Search UI / Going further

Improve performance for Angular InstantSearch

Angular InstantSearch isn’t compatible with Angular’s Ivy view engine. We’re investigating how best to support this. For more information and to vote for Algolia’s support of Angular 16 and beyond, see the GitHub issue Algolia Support for Angular InstantSearch

Algolia is fast by default. But network speed and bandwidth can vary. This page lists a few best practices you can implement to adapt to your users’ network conditions.

Prepare the connection to Algolia

When sending the first network request to a domain, a security handshake must happen, consisting of several round trips between the client and the Algolia server. If the handshake first happens when users type their first keystroke, the speed of that first request would be significantly slower.

Use a preconnect link to carry out the handshake immediately after loading the page but before any user interaction. To do this, add a link tag with your Algolia domain in the head of your page.

1
2
3
4
<link crossorigin href="https://YOUR_APPID-dsn.algolia.net" rel="preconnect" />

<!-- For example: -->
<link crossorigin href="https://B1G2GM9NG0-dsn.algolia.net" rel="preconnect" />

Add a loading indicator

Consider a user accessing your app in a subway:

  1. They type some characters
  2. Nothing happens
  3. They wait, but still, nothing happens

However, you can enhance the user experience by displaying a loading indicator to indicate something is happening.

To do this, create a loading indicator with a custom widget based on connectSearchBox. The following example displays Loading… if a search request takes longer than expected.

If network conditions are optimal, users won’t see this message.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { Component, Inject, forwardRef, Optional } from '@angular/core'
import {
  TypedBaseWidget,
  NgAisInstantSearch,
  NgAisIndex,
} from 'angular-instantsearch'
import connectSearchBox, {
  SearchBoxWidgetDescription,
  SearchBoxConnectorParams,
} from 'instantsearch.js/es/connectors/search-box/connectSearchBox'

@Component({
  selector: 'app-loading-indicator',
  template: `
    <div *ngIf="state.isSearchStalled && state.isSearchStalled">Loading...</div>
  `,
})
export class LoadingIndicatorComponent extends TypedBaseWidget<
  SearchBoxWidgetDescription,
  SearchBoxConnectorParams
> {
  public state: SearchBoxWidgetDescription['renderState'] = {
    clear(): void {},
    isSearchStalled: false,
    query: '',
    refine(value: string): void {},
  }

  // Rendering options
  constructor(
    @Inject(forwardRef(() => NgAisIndex))
    @Optional()
    public parentIndex: NgAisIndex,
    @Inject(forwardRef(() => NgAisInstantSearch))
    public instantSearchInstance: NgAisInstantSearch
  ) {
    super('SearchBox')
  }

  ngOnInit() {
    this.createWidget(connectSearchBox, {
      // instance options
    })
    super.ngOnInit()
  }
}

Debouncing

Another way of improving the perception of performance is by preventing lag. Although the default InstantSearch experience of generating one query per keystroke is usually desirable, this can lead to a lag in the worst network conditions because browsers can only make a limited number of parallel requests. By reducing the number of requests, you can prevent this lag.

Debouncing limits the number of requests and avoid processing non-necessary ones by avoiding sending requests before a timeout.

Implement debouncing at the ais-search-box level with the help of the connectSearchBox connector. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { Component, Inject, forwardRef, Optional, Input } from '@angular/core'
import {
  TypedBaseWidget,
  NgAisInstantSearch,
  NgAisIndex,
} from 'angular-instantsearch'
import connectSearchBox, {
  SearchBoxWidgetDescription,
  SearchBoxConnectorParams,
} from 'instantsearch.js/es/connectors/search-box/connectSearchBox'

@Component({
  selector: 'app-debounced-search-box',
  template: `
    <input
      type="text"
      #input
      (keyup)="onChangeDebounced(input.value)"
      [value]="this.state.query"
    />
  `,
})
export class DebouncedSearchBoxComponent extends TypedBaseWidget<
  SearchBoxWidgetDescription,
  SearchBoxConnectorParams
> {
  private timerId: ReturnType<typeof setTimeout> | null = null
  @Input() delay: number = 0
  public state: SearchBoxWidgetDescription['renderState'] = {
    clear(): void {},
    isSearchStalled: false,
    query: '',
    refine(value: string): void {},
  }

  // Rendering options
  constructor(
    @Inject(forwardRef(() => NgAisIndex))
    @Optional()
    public parentIndex: NgAisIndex,
    @Inject(forwardRef(() => NgAisInstantSearch))
    public instantSearchInstance: NgAisInstantSearch
  ) {
    super('SearchBox')
  }

  public onChangeDebounced(value: string) {
    if (this.timerId) clearTimeout(this.timerId)
    this.timerId = setTimeout(() => this.state.refine(value), this.delay)
  }

  ngOnInit() {
    this.createWidget(connectSearchBox, {
      // instance options
    })
    super.ngOnInit()
  }
}
1
<app-debounced-search-box [delay]="200"></app-debounced-search-box>

Find the complete source code on GitHub.

Optimize build size

InstantSearch supports dead code elimination through tree shaking, but you must follow a few rules for it to work:

  • Bundle your code using a module bundler that supports tree shaking with the sideEffects property in package.json, such as Rollup or webpack 4+.
  • Make sure you pick the ES module build of InstantSearch by targeting the module field in package.json (resolve.mainFields option in webpack, mainFields option in @rollup/plugin-node-resolve). This is the default configuration in most popular bundlers: you only need to change something if you have a custom configuration.
  • Keep Babel or other transpilers from transpiling ES6 modules to CommonJS modules. Tree shaking is much less optimal on CommonJS modules, so it’s better to let your bundler handle modules by itself.

If you’re using Babel, you can configure babel-preset-env not to process ES6 modules:

1
2
3
4
5
6
7
8
9
10
11
// babel.config.js
module.exports = {
  presets: [
    [
      'env',
      {
        modules: false,
      },
    ],
  ],
}

If you’re using the TypeScript compiler (tsc):

1
2
3
4
5
6
// tsconfig.json
{
  "compilerOptions": {
    "module": "esnext",
  }
}

Import only what you need, and avoid importing NgAisModule. Doing so imports all the widgets, even the ones you don’t use.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {
  NgAisInstantSearchModule,
  NgAisHitsModule,
  NgAisSearchBoxModule,
} from 'angular-instantsearch'

@NgModule({
  imports: [
    NgAisInstantSearchModule.forRoot(),
    NgAisHitsModule,
    NgAisSearchBoxModule,
  ],
})
export class AppModule {}

Troubleshooting

To check if tree shaking works, try to import InstantSearch into your project without using it.

1
import 'angular-instantsearch' // Unused import

Build your app, then look for the unused code in your final bundle (for example, “InstantSearch”). If tree shaking works, you shouldn’t find anything.

Caching

Caching by default (and how to turn it off)

By default, Algolia caches the search results of the queries, storing them locally in the cache. This cache only persists during the current page session, and as soon as the page reloads, the cache clears.

If users type a search (or part of it) that’s already been entered, the results will be retrieved from the cache instead of requesting them from Algolia, making the app much faster.

While it’s a convenient feature, sometimes you may want to clear the cache and make a new request to Algolia. For instance, when changes are made to some records in your index, you should update your app’s frontend to reflect that change (and avoid displaying stale results retrieved from the cache).

The refresh function, available for custom connectors, lets you clear the cache and trigger a new search.

When to discard the cache

Consider discarding the cache when your app’s data is updated by:

  • Your users (for example, in a dashboard). In this case, refresh the cache based on an app state, such as the last user modification.
  • Another process you don’t manage (for example, a cron job that updates users inside Algolia). In this case, you should refresh your app’s cache periodically.

Refresh the cache triggered by a user action

The following code triggers a refresh based on a user action (such as adding a new product or clicking a button). The complete source code is on GitHub.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { Component, Inject, forwardRef, Optional, Input } from '@angular/core'
import {
  TypedBaseWidget,
  NgAisInstantSearch,
  NgAisIndex,
} from 'angular-instantsearch'
import { Connector } from 'instantsearch.js/es/types'

type RefreshWidgetDescription = {
  $$type: 'custom.referesh'
  renderState: {
    refresh(): void
  }
}

type RefreshConnectorParams = {}

type RefreshConnector = Connector<
  RefreshWidgetDescription,
  RefreshConnectorParams
>

const connectRefresh: RefreshConnector =
  (renderFn, unmountFn) => (widgetParams) => ({
    $$type: 'custom.referesh',
    init: function ({ instantSearchInstance }) {
      renderFn(
        {
          refresh() {},
          widgetParams,
          instantSearchInstance,
        },
        true
      )
    },
    render({ instantSearchInstance }) {
      const refresh = instantSearchInstance.refresh.bind(instantSearchInstance)
      renderFn({ refresh, widgetParams, instantSearchInstance }, false)
    },
    dispose() {
      if (unmountFn) {
        unmountFn()
      }
    },
  })

@Component({
  selector: 'app-refresh',
  template: ` <button (click)="state.refresh()">Refresh</button> `,
})
export class RefreshComponent extends TypedBaseWidget<
  RefreshWidgetDescription,
  RefreshConnectorParams
> {
  public state: RefreshWidgetDescription['renderState'] = {
    refresh(): void {},
  }

  // Rendering options
  constructor(
    @Inject(forwardRef(() => NgAisIndex))
    @Optional()
    public parentIndex: NgAisIndex,
    @Inject(forwardRef(() => NgAisInstantSearch))
    public instantSearchInstance: NgAisInstantSearch
  ) {
    super('Refresh')
  }

  ngOnInit() {
    this.createWidget(connectRefresh, {
      // instance options
    })
    super.ngOnInit()
  }
}

And use it within your app:

1
<app-refresh></app-refresh>

Refresh the cache periodically

You can set an interval to determine how often the app clears the cache. Use this approach if you can’t trigger cache clearance based on user actions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import { Component, Inject, forwardRef, Optional, Input } from '@angular/core'
import {
  TypedBaseWidget,
  NgAisInstantSearch,
  NgAisIndex,
} from 'angular-instantsearch'
import { Connector } from 'instantsearch.js/es/types'

type RefreshWidgetDescription = {
  $$type: 'custom.referesh'
  renderState: {
    refresh(): void
  }
}

type RefreshConnectorParams = {}

type RefreshConnector = Connector<
  RefreshWidgetDescription,
  RefreshConnectorParams
>

const connectRefresh: RefreshConnector =
  (renderFn, unmountFn) => (widgetParams) => ({
    $$type: 'custom.referesh',
    init: function ({ instantSearchInstance }) {
      renderFn(
        {
          refresh() {},
          widgetParams,
          instantSearchInstance,
        },
        true
      )
    },
    render({ instantSearchInstance }) {
      const refresh = instantSearchInstance.refresh.bind(instantSearchInstance)
      renderFn({ refresh, widgetParams, instantSearchInstance }, false)
    },
    dispose() {
      if (unmountFn) {
        unmountFn()
      }
    },
  })

@Component({
  selector: 'app-refresh-periodically',
  template: `Periodic refresh every {{ delay }} seconds`,
})
export class PeriodicRefreshComponent extends TypedBaseWidget<
  RefreshWidgetDescription,
  RefreshConnectorParams
> {
  private timerId: ReturnType<typeof setInterval> | null = null
  @Input() delay: number = 10000

  public state: RefreshWidgetDescription['renderState'] = {
    refresh(): void {},
  }

  // Rendering options
  constructor(
    @Inject(forwardRef(() => NgAisIndex))
    @Optional()
    public parentIndex: NgAisIndex,
    @Inject(forwardRef(() => NgAisInstantSearch))
    public instantSearchInstance: NgAisInstantSearch
  ) {
    super('RefreshPeriodically')
  }

  ngOnInit() {
    this.createWidget(connectRefresh, {
      // instance options
    })

    this.timerId = setInterval(() => {
      this.state.refresh()
    }, this.delay)

    super.ngOnInit()
  }

  ngOnDestroy() {
    if (this.timerId) {
      clearInterval(this.timerId)
    }
    super.ngOnDestroy()
  }
}

then use it as

1
<app-refresh-periodically [delay]="10000"></app-refresh-periodically>

If you need to wait for an action from Algolia, use waitTask to avoid refreshing the cache too early.

Queries per second (QPS)

Search operations aren’t limited by a fixed “search quota”. Instead, they’re limited by your plan’s maximum QPS and operations limit.

Every keystroke in InstantSearch using the ais-search-box counts as one operation. Then, depending on the widgets you add to your search interface, you may have more operations being counted on each keystroke. For example, if you have a search interface with an ais-search-box, an ais-hierarchical-menu, and an ais-refinement-list, then every keystroke triggers one operation. But as soon as a user refines the ais-hierarchical-menu or ais-refinement-list, it triggers a second operation on each keystroke.

If you experience QPS limitations, consider implementing a debounced ais-search-box.

Did you find this page helpful?