DEV Community

Cover image for How We Cut 80 % of Our Backend Calls in Summon Worlds
Jedidiah Weller 👑 for OpenForge

Posted on

How We Cut 80 % of Our Backend Calls in Summon Worlds

TL;DR

  • Reduce redundant API calls by centralizing loading logic in a shared helper.
  • Always expose a domain model (class instance) instead of raw state slices.
  • Cache with shareReplay so multiple subscribers share one request.
  • Dispatch only plain-object payloads—no class instances in actions.
  • Inspired by our experience in the Summon Worlds AI world-building app.

0. The Backstory 🏗️

First things first - a story. In September of last year, we took over an AI World Building App called Summon Worlds. The app was a cool idea, had an great community, and scratched that gaming itch that all developers have.

So after we took it on, we rebuilt the (very hacky) flutter prototype in Ionic + Angular, and we decided to use NGRX to help with performance and storage calls. Since we were in a hurry, that was about all the thought we put into the pattern in which we were implementing NGRX and we thought it would be an easy process.

It was not.

So, fast forward till today - there were a ton of architecture and pattern decisions that I wish we did differently, and I'd like to share some of them with you so that hopefully you do not make the same mistakes as we did.

This particular article is focused on an "accidental" antipattern of NGRX + Clone Deep that caused major issues. If you like it let me know, and I'll write a couple more about some other anti-patterns to avoid!

1. How We Discovered The Antipattern 🏗️

The State was managed via NgRx and persisted to Firebase. One morning we noticed our billing spike—and Cloud Functions logs revealed that loading our simple “feed” screen fired 42 loadEntity calls every time. On slower networks, users waited ages. We knew something had to change.


2. Recognizing the Anti-Pattern

2.1 The Over-eager Helper

A common helper service looked like this:

@Injectable({ providedIn: 'root' }) export class GenericHelperService { constructor(private store: Store<AppState>) {} // Raw state slice used everywhere getEntityState$(): Observable<EntityState> { return this.store.select(selectEntityState); } // Lightweight shortcut mapping to a class getEntity$(): Observable<EntityModel> { return this.store.select(selectEntityData).pipe( map(data => new EntityModel(data)) ); } // Dispatching class instances directly updateEntity(model: EntityModel) { this.store.dispatch(updateEntity({ payload: model })); } } 
Enter fullscreen mode Exit fullscreen mode

Every component calling getEntity$() would dispatch loadEntity again and again, re-instantiate the model, and violate NgRx’s serialization rules.


3. Core Principles of the Fix

  1. Raw slice only for edge cases.
  2. Instantiate your domain model exactly once in a shared helper.
  3. Dispatch only plain JSON payloads—no class instances in actions.

These principles rescued our performance in Summon Worlds and will help any NgRx project.


4. A Generic Refactor Recipe

4.1 Strengthen the Helper

@Injectable({ providedIn: 'root' }) export class GenericHelperService { constructor(private store: Store<AppState>) {} /** Raw slice — cache and reuse sparingly */ getEntityState$(): Observable<EntityState> { return this.store.select(selectEntityState).pipe( shareReplay(1) ); } /** Central loader + model emitter */ getEntity$(id: string): Observable<EntityModel> { // Trigger load exactly once per id this.store.dispatch(loadEntity({ id })); return combineLatest([ this.store.select(selectEntityById(id)), this.store.select(selectEntityLoading(id)) ]).pipe( filter(([data, loading]) => !!data && !loading), map(([data]) => new EntityModel(data)), shareReplay({ bufferSize: 1, refCount: true }) ); } /** Mutations accept model, dispatch plain data */ saveChanges(model: EntityModel): void { const payload = model.toPlainObject(); this.store.dispatch(updateEntity({ payload })); } } 
Enter fullscreen mode Exit fullscreen mode
  • shareReplay(1) shares one subscription, caching the latest value.
  • combineLatest + filter waits until data is ready before emitting.
  • toPlainObject() strips methods, leaving a serializable payload.

4.2 Use the Async Pipe Everywhere

Before (Summon Worlds style):

ngOnInit() { this.sub = this.helper.getEntity$(id) .subscribe(model => this.value = model.compute()); } ngOnDestroy() { this.sub.unsubscribe(); } 
Enter fullscreen mode Exit fullscreen mode

After:

value$ = this.helper.getEntity$(id).pipe( map(model => model.compute()) ); 
Enter fullscreen mode Exit fullscreen mode
<div>{{ value$ | async }}</div> 
Enter fullscreen mode Exit fullscreen mode

No manual subscriptions, no memory leaks, no boilerplate.


4.3 Remove Deep-Clone Workarounds

Swap out:

const safeData = cloneDeep(rawData)!; 
Enter fullscreen mode Exit fullscreen mode

For:

computed$ = this.helper.getEntity$(id).pipe( map(model => model.optionalProp ?? defaultValue) ); 
Enter fullscreen mode Exit fullscreen mode

TypeScript strict checks catch missing properties, eliminating the cloneDeep band-aid.


4.4 Centralize Error Handling

Instead of catching errors in every component:

this.helper.getEntity$(id).pipe( catchError(err => { console.error(err); return of(null); }) ); 
Enter fullscreen mode Exit fullscreen mode

Handle failures in an effect:

loadEntityFailure$ = createEffect(() => this.actions$.pipe( ofType(loadEntityFailure), tap(({ error }) => this.toast.error(`Load failed: ${error}`)) ), { dispatch: false } ); 
Enter fullscreen mode Exit fullscreen mode

Your components only worry about rendering the happy path.


5. Migration Checklist

  1. Helper overhaul
  • Add shareReplay on raw-slice methods.
  • Implement getEntity$() with load dispatch, combineLatest, filter, mapping, and shareReplay.

    1. Component cleanup
  • Replace manual subscriptions with async-pipe streams.

    1. Payload serialization
  • Add .toPlainObject() to your models.

  • Dispatch only JSON in actions.

    1. Remove cloneDeep
  • Delete all deep-clone code.

    1. Centralize error flow
  • Catch errors in effects only.

    1. Enable runtime checks
 StoreModule.forRoot(reducers, { runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true, } }) 
Enter fullscreen mode Exit fullscreen mode

6. Measuring Success

Metric Before After Improvement
Backend calls per view 42 4 −90%
Memory on initial render (MB) 28 18 −36%
Time to first paint (s) 4.2 2.7 −1.5

(These are some of the actual numbers we achieved, fyi)


7. FAQs

Q: Why not dispatch class instances?
A: They break DevTools serialization, effect replay, and cross-tab sync. POJOs are the safest contract.

Q: Aren’t selectors memoized?
A: Yes—but extra loadEntity dispatches before data arrives bypass selector caching. shareReplay fixes that gap.


8. Generic Snippets to Copy

Model Skeleton

export class EntityModel { constructor(private data: EntityData) {} get id(): string { return this.data.id; } compute(): number { /* domain logic */ return 0; } toPlainObject(): EntityData { return { ...this.data }; } } 
Enter fullscreen mode Exit fullscreen mode

Helper Skeleton

@Injectable({ providedIn: 'root' }) export class GenericHelperService { constructor(private store: Store<AppState>) {} getEntity$(id: string): Observable<EntityModel> { this.store.dispatch(loadEntity({ id })); return combineLatest([ this.store.select(selectEntityById(id)), this.store.select(selectEntityLoading(id)) ]).pipe( filter(([data, loading]) => !!data && !loading), map(([data]) => new EntityModel(data)), shareReplay({ bufferSize: 1, refCount: true }) ); } } 
Enter fullscreen mode Exit fullscreen mode

9. Next Steps

  • Measure your own before/after metrics.
  • Refactor one entity at a time.
  • Share this guide and (of course) check out Summon Worlds if you are into AI world-building.

Happy refactoring—and may your selectors be ever memoized!

Top comments (0)