Discover the latest and most effective method for implementing time-based caching of API responses (or any other kind of RxJs streams) directly within our Angular apps!

emoji_objects emoji_objects emoji_objects
Tomas Trajan

Tomas Trajan

@tomastrajan

Jul 7, 2022

9 min read

The Best New Way To Cache API Responses with Angular and RxJs
share

The moment is here—clean RxJs caching based on time is finally within reach! 📸 photo by Elena Koycheva & 🎨 artwork by Tomas Trajan

Hi everyone, glad you're here! 👋

This post is quite different from my usual ones!

Time and again, I've found myself tangled up trying to tackle this particular scenario elegantly. It often dragged in coworkers and consumed far too many minutes, yet never quite landed.

Sure, we could always patch together something that worked, but the outcome left a bad taste—nothing about it felt right.

But that's all changing now…

In this post, we'll flip the script and uncover the optimal modern approach for adding time-based caching to API responses—or any other—RxJs streams within our Angular projects!

☕ This one zeroes in on a single concept, so you should probably breeze through it without needing a break, still, a quick TLDR never hurts 😉

TLDR

  • Begins with the real-world scenario of fetching and caching the apiKey
  • Looks back at prior strategies (from before RxJs 7.1) and where they fell short
  • Introduces a smarter fix using the upgraded share operator, arriving with RxJs 7.1+
  • Walks through reworking our initial code
  • Highlights pitfalls, common traps, and a side-by-side of options with their pros and cons
  • Offers a live demo (StackBlitz) plus a handy Cheat Sheet

The Original Use Case

Suppose we have an Angular app where hitting a API endpoint (i.e., backend) demands two HTTP headers to be set:

  1. The familiar access-token, like a JWT acquired during sign-in
  2. A bespoke api-key fetched from its own endpoint (which we can do, given the access-token we already hold is enough for that…)

Additionally, the api-key expires on a schedule that's hard to pin down exactly—don't ask me for the logic behind it… Just know it will hold for at least 10 seconds from the moment it's fetched, possibly stretching up to a full month 😅😅

Let’s see what paths we have to address this challenge…

👎Pulling a fresh key on every single call

An Angular interceptor responsible for attaching HTTP headers could request a brand-new API key per each outgoing request

  • ✅ This removes any worry about failures from stale keys—all calls stay safe
  • ❌ Yet, grabbing a new key each time means every request suffers a wait, since we're effectively making two HTTP calls rather than one
// api key service
@Injectable({ providedIn: 'root' })
export class ApiKeyService {
  // cold stream definition
  apiKey$ = this.httpClient.get<string>(API_KEY_ENDPOINT);

  constructor(private httpClient: HttpCLient) {}
}

// auth interceptor
export class AuthInterceptor implements HttpInterceptor {
  constructor(
    private accessTokenService: AccessTokenService,
    private apiKeyService: ApiKeyService,
  ) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandLer,
  ): Observable<HttpEvent> {
    const accessToken = this.accessTokenService.getAccessToken();
    return apiKeyService.apiKey$.pipe(
      // will trigger extra backend request
      concatMap((apiKey) => {
        request = request.clone({
          setHeaders: {
            Authorization: `Bearer ${accessToken}`,
            'x-api-key': apiKey,
          },
        });
        return next.handle(request);
      }),
    );
  }
}

Here's a scenario that ends up firing an extra request just to obtain a fresh API key each time a real call is made

In that setup, ApiKeyService defines the apiKey$ stream as a cold observable. As a result, every single actual request routed through the intercept() method of the AuthInterceptor re-executes that stream and pulls a new key.

👎Fetch the API key once, cache it forever. After that, cross your fingers and rely on retries for any expired-key failures 🤞

  • ✅ This is great for performance—the key is loaded on the first call and cached for all subsequent requests—but there's a catch…
  • ❌ If the cached key turns out to be invalid, the request will fail. We'd then need custom logic to fetch a new key and replay the failed request, which adds a significant chunk of code to write and maintain…

👎 Schedule periodic key refreshes

  • ✅ This way, we avoid any chance of a request failing due to an outdated key…
  • ❌ Constantly renewing the key puts extra load on the backend and can get pricey with cloud services. Picture thousands of concurrent users leaving the app tab open without touching it—they aren't making any actual requests, yet we'd still be pulling a new API key every minute or so…
@Injectable({ providedln: 'root' })
export class ApiKeyService {
  apiKey: string; // will be accessed sync with apiKeyService.apiKey
  constructor(private httpCtient: HttpCtient) {
    this.timer(0, 60_000)
      .pipe(switchMap(() => this.httpClient.get<string>(API_KEY_ENDPOINT)))
      .subscribe((apiKey) => {
        this.apiKey = apiKey;
      }); // no need to unsubscribe, global singleton
    // should run for the whole app life time...
  }
}

Here’s a solution example where the API key gets refreshed at set intervals—say, once per minute.

Let’s dive into the first practical approach for tackling our API key caching scenario!

😑Fetch the key on demand (only when needed) and hold onto it for a brief window

  • ✅ Zero extra requests if the app sits idle (for instance, when the tab stays open in the background)
  • ✅ No slowdown — concurrent requests share the same cached API key, avoiding a fresh fetch each time
  • ✅ No custom retry mechanisms required, since the key stays fresh enough to make that case irrelevant
  • ❌ Messy to implement, with manual local state handling, not a pure stream-driven RxJs solution, and access goes through a factory rather than straightforward public property exposure…
@Injectable({ providedln: 'root' })
export class ApiKeyService {
  apiKey$: Observable<string>;

  constructor(private httpCtient: HttpCtient) {}

  getApiKey() {
    if (this.apiKey$) {
      // if key exists
      return this.apiKey$; // return cachec API key
    } else {
      this.apiKey$ = this.httpClient
        .get<string>(API_KEY_ENDPOINT)
        .pipe(shareReplay(1)); // retrieve new API key
      setTimeout(() => {
        // setup cache invalidation
        this.apiKey$ = undefined; // unset stream
      }, CACHE_TIMEOUT); // cache invalidation timeout
    }
  }
}

Here’s an example approach that implements time-based stream caching. While functional, it relies on manual local state management rather than a self-contained RxJs stream…

Why our custom RxJs caching falls short

The earlier solution performs as expected, but it lacks elegance and clarity…

We end up juggling local state with imperative logic around the RxJs stream, which feels out of place…

It is always* possible to design an RxJs stream that accounts for all sources of change upfront, without needing to recreate streams during runtime

*in the past 6 years, this claim has held — the only exception I've hit was this case, which I couldn't solve without stream recreation at runtime 😔

Stay positive, though: the release of RxJs 7.1 changes everything, thanks to its enhanced share operator that lets us handle it properly! 💪

Follow me on Twitter to catch new Angular blog posts and other frontend insights!😉

Getting to know the enhanced share operator

RxJs 7.1 introduces a significantly upgraded share operator, along with more robust configuration capabilities!

Now, let’s rework our previous caching solution that used local state…

const CACHE_TIMEOUT = 10 * 1000; // 10 seconds

@Injectable({ providedln: 'root' })
export class ApiKeyService {
  apiKey$ = this.httpClient.get<string>(API_KEY_ENDPOINT).pipe(
    tap(() => console.log('[DEBUG] request happened')),
    share({
      // HttpClient.get is a completing stream
      // eg '---a|' (marble diagram)
      resetOnComplete: () => timer(CACHE_TIMEOUT),
      // as it completes, we start a timer which will reset the stream
      // when finished, this means that the last API key value will be
      // shared with all subscribers until the timer is triggered which
      // is the desired time-based caching behavior
    }),
  );

  constructor(private httpCtient: HttpCtient) {}
}

Here’s a refined, purely RxJs-based approach to time-based caching tailored for our API key scenario

  • ✅ With this design, there’s no need for local state or a stream factory method—everything stays neatly encapsulated within the RxJs stream itself
  • ✅ Simply store a stream definition in a public service property, and access it via apiKeyService.apiKey$ property access
  • ✅ The stream is cold and lazy—no request fires until the first subscriber hooks in! In practice, it waits until the app initiates an initial “real” call to any other endpoint

Let’s observe how this solution performs during execution!

@Injectable()
class AuthInterceptor {
  constructor(private apiKeyService: ApiKeyService) {}

  intercept() {
    // unrealistic, for demonstration purposes only

    this.apiKeyService.apiKey$.subscribe(console.log); // logs: [DEBUG] request
    // logs: apiKey1
    this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey1
    this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey1
    this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey1

    setTimeout(() => {
      this.apiKeyService.apiKey$.subscribe(console.log); // ⚠️ doesn't log anything !?
      this.apiKeyService.apiKey$.subscribe(console.log);
    }, 1000); // less

    setTimeout(() => {
      this.apiKeyService.apiKey$.subscribe(console.log); // logs: [DEBUG] request
      // logs: apiKey2
      this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey2
    }, 11_000); // more than caching timeout
  }
}

Here is a sample timeline illustrating how our new pure RxJs time-based stream caching solution behaves

Observe that the initial subscription fires the API-key request, while subsequent subscribers receive the cached API key without any additional request being triggered, excellent!

However, after the cache timeout has elapsed, the next subscription will cause a fresh request, and the new response is then cached once more…

This cycle continues as long as new subscriptions appear (in our scenario, requests handled by the interceptor)—so it only runs while the user is actively engaging with the application, which is as lazy as it gets 👍

⚠️ Yet, the current solution still has a flaw…

Subscriptions occurring later—still within the caching window but after the initial one—did not trigger a new request ✅ but they also failed to receive any API key ❌ … (note the ⚠️ icon in the code snippet above)

Let’s address this issue with our final tweak

The share operator relies internally on an RxJs subject, as the Subject is how RxJs implements multicasting for an Observable.

We can modify this behavior of share by providing a custom connector option.

In its default form, connector uses a standard RxJs Subject, which emits values in a manner best described as “fire and forget”. That explains why subscribers joining our API-key stream after a delay received nothing: the emission happened in the past, and the plain Subject retains no memory of the last emitted value!

Thankfully, fixing this is straightforward—we just override connector with the RxJs ReplaySubject.

The right behavior is achieved with connector: () => new ReplaySubject(1), since we only care about the most recent API key!

Let’s see this in practice 😉

const CACHE_TIMEOUT = 10 * 1000; // 10 seconds

@Injectable({ providedln: 'root' })
export class ApiKeyService {
  apiKey$ = this.httpClient.get<string>(API_KEY_ENDPOINT).pipe(
    tap(() => console.log('[DEBUG] request happened')),
    share({
      // fix the problem where later subscribers
      // did not receive cached API key
      connector: () => new ReplaySubject(1), // override default "new Subject()"
      resetOnComplete: () => timer(CACHE_TIMEOUT),
    }),
  );

  constructor(private httpCtient: HttpCtient) {}
}

Completed demo featuring a fully functional fix that matches our intended behavior precisely!

Now, let’s examine how the corrected implementation performs in action…

@Injectable()
class AuthInterceptor {
  constructor(private apiKeyService: ApiKeyService) {}

  intercept() {
    // unrealistic, for demonstration purposes only

    this.apiKeyService.apiKey$.subscribe(console.log); // logs: [DEBUG] request
    // logs: apiKey1
    this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey1
    this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey1
    this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey1

    setTimeout(() => {
      this.apiKeyService.apiKey$.subscribe(console.log); // ✅ logs: apiKey1
      this.apiKeyService.apiKey$.subscribe(console.log); // ✅ logs: apiKey1
    }, 1000); // less

    setTimeout(() => {
      this.apiKeyService.apiKey$.subscribe(console.log); // logs: [DEBUG] request
      // logs: apiKey2
      this.apiKeyService.apiKey$.subscribe(console.log); // logs: apiKey2
    }, 11_000); // more than caching timeout
  }
}

When a delayed subscription occurs—still falling inside the caching window yet arriving after the initial subscription—no new request is made ✅, and the most recent API key is delivered correctly ✅. Excellent! 🎉

Don’t forget to explore the live demo on StackBlitz!

A concise cheat sheet for your teammates 😉

The Best New Way To Cache API Responses with Angular and RxJs - Angular Experts — figure 3

BONUS: Why did we pick ReplaySubject instead of the more widely-used BehaviorSubject?

In the final stage of our implementation, we overrode the connector option, which defaults to new Subject(), by setting it to connector: () => new ReplaySubject(1).

This solution resolved our problem where late subscribers missed the latest stored API key because the standard Subject behaves in a manner often characterized as “fire and forget”.

You might be asking why we selected new ReplaySubject(1) over new BehaviorSubject('')—a fair question that provides deeper insights into our decision!

  • ✅ Both varieties retain the most recent stream value, ensuring that subscribers joining after the emission still receive it; this avoids the data loss seen with a plain Subject
  • ✅ While ReplaySubject(1) caches the last emitted value for future subscribers, it does **NOT **require an initial value
  • ⚠️ Conversely, BehaviorSubject('some initial value') necessitates a starting value that it forwards instantly to new subscribers. In our context, that would either cause an error since 'some initial value' isn’t a legitimate API key, or force us to add a filter(apiKey => apiKey !== 'some initial value') to block it…

We’re done! 💪

I hope you found this exploration of caching backend API responses in your Angular apps valuable, leveraging the enhanced share operator that came with RxJs 7.1.

Now that you have this knowledge, apply it to cache your streams where appropriate, creating faster applications and preserving user bandwidth!

If anything is unclear, feel free to reach out via comments on this article or direct messages on Twitter @tomastrajan

And always remind yourself—the future is bright

Obviously the bright  Future! Clearly, that's the future awaiting us! (📷 by [Kamil Kalbarczyk](https://unsplash.com/@kamilkalb))

Appreciate the look of the code preview? Check out our brand-new theme plugin

Skol - the ultimate IDE theme

Skol - the ultimate IDE theme

Your editor gets the aurora experience. A minimal yet robust dark theme that performs well and is gentle on the eyes.

Craft smarter interfaces with Angular + AI

Angular + AI Video Training

Angular + AI Video Course

A practical, step-by-step tutorial for embedding AI directly into Angular applications with Hash Brown, enabling smart, reactive interfaces.

Dive into real-time chat streaming, invoking tools, generative UI components, structured outputs, and beyond.

Looking for a thorough walkthrough of Angular's Signal Forms architecture, validation techniques, and upgrade paths?

The Angular Signal Forms Guide

Angular Signal Forms eBook

Build typed, validated, production-ready Angular forms with signals using a model-first approach.

Learn schema-driven validation, form-state signals, custom controls, Reactive Forms migration, and clean API mapping patterns.

Do you enjoy the content and want to master Angular's brand new Signal Forms?

Angular Signal Forms: Hands-On Masterclass

Angular Signal Forms: Hands-On Masterclass

Dive into Angular's recently introduced Signal-Forms across a dozen carefully scaffolded chapters that blend concepts with practical exercises.

Explore everything from form foundations and validation to bespoke controls, nested forms, and effective upgrade paths!

Win win deal illustration

Stay in the loop
with fresh articles

Subscribe to Angular Experts Content Updates & News, and we’ll let you know the moment a new post goes live—covering Angular, Ngrx, RxJs, and other exciting Frontend topics!

Your email stays confidential with us, and you’re free to opt out at any time!

Some emails might include extra promotional content—check our Privacy policy for the full details.

Your take & feedback

Feel free to ask anything, share your own insights, or add your perspective to the conversation

Tomas Trajan - GDE for Angular & Web Technologies

Tomas Trajan

Google Developer Expert (GDE)
for Angular & Web Technologies

Google Developer Experts logo X logo LinkedIn logo Github logo Github logo Spotify logo Medium logo public

My consulting and training services help developer teams build successful Angular apps, with a strong emphasis on Architecture and State management using NgRx!

As a Google Developer Expert for Angular & Web Technologies, I work as both a consultant and Angular trainer. I'm currently assisting teams in large enterprises across the globe with core feature implementation, architectural design, best-practice adoption, knowledge sharing, and workflow improvements.

Tomas is committed to delivering high value to both clients and the broader development community. This dedication is reflected in his extensive portfolio of widely-read industry articles, keynote presentations at international conferences and meetups, and active participation in open-source projects.

52

Blog posts

4.7M

Blog views

3.5K

Github stars

612

Trained developers

39

Given talks

8

Capacity to eat another cake

You might also like

Dive into more articles from Angular Experts to explore additional subjects such as RxJs or Angular !

Top 10 Angular Architecture Mistakes You Really Want To Avoid

Top 10 Angular Architecture Mistakes You Really Want To Avoid

In 2024, Angular keeps changing for better with ever increasing pace, but the big picture remains the same which makes architecture know-how timeless and well worth your time!

emoji_objects emoji_objects emoji_objects
Tomas Trajan

Tomas Trajan

@tomastrajan

Sep 10, 2024

15 min read

Angular Signal Inputs

Angular Signal Inputs

Revolutionize Your Angular Components with the brand new Reactive Signal Inputs.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Jan 24, 2024

6 min read

Improving DX with new Angular @Input Value Transform

Improving DX with new Angular @Input Value Transform

Embrace the Future: Moving Beyond Getters and Setters! Learn how to leverage the power of custom transformers or the build in booleanAttribute and numberAttribute transformers.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Nov 18, 2023

3 min read

Our extensive experience is here to boost your team

Consulting with enterprises and startups alike, directing workshops, and maintaining robust open source resources have been core pursuits of Angular Experts for years. We take immense pride in modern front-end expertise, and we would be delighted to see your business flourish with our support.