GraphQL from Angular: Apollo Angular, Code Generation, Subscriptions and @defer
The solid way to connect Angular to GraphQL is Apollo Angular with a normalized cache, typed operations from GraphQL Code Generator, and the persisted operation manifest that same codegen produces. With Angular 22 and Apollo Client 4 we show signals and dataState, cache consistency after mutations, WebSocket subscriptions, the fetch-based link @defer needs, and the empty screen caused by a missing __typename in persisted documents, all with real output.
Version note: The code in this post was built and run with .NET 10, Hot Chocolate 16.7, Angular 22.2, Apollo Angular 14.2, Apollo Client 4.3, graphql-ws 6.4 and GraphQL Code Generator 8 (client preset 7.0).
Guess First
In the previous post we set up a server that accepts only persisted operations in production. In this post you extract the Angular client's operations with GraphQL Code Generator and write them into the server's store. The book detail page opens; in the Network tab the request is a 200, and both reviews are in the response:
{"incremental":[{"id":"2","data":{"reviews":[{"id":10,"rating":5,"text":"Great chapter on retries."},{"id":7,"rating":5,"text":"A bit long."}]}}],"completed":[{"id":"2"}],"hasNext":false}
But on screen the review list looks like this: /5 · | /5 ·. Two rows, both empty. On top of that, when you change the order status with the "Advance" button, the mutation succeeds but the status on screen doesn't change. The server sends the right data; where is the problem?
The answer lies in a single field missing from the response. We'll see it in the persisted operations section; first let's set up the client.
Setup: Angular 22, Apollo Angular 14 and Apollo Client 4
On the day of writing, the latest Apollo Angular is 14.2.0, and its peer dependencies cover Angular 20, 21 and 22 and the 4.x line of Apollo Client. The packages:
npm install @apollo/client@4.3.8 apollo-angular@14.2.0 graphql@17.1.0 graphql-ws@6.4.2
npm install -D @graphql-codegen/cli@8.0.0 @graphql-codegen/client-preset@7.0.0
During development the Angular dev server forwards /graphql requests to the Catalog service; ws: true carries WebSocket connections too:
{
"/graphql": {
"target": "http://localhost:5200",
"ws": true
}
}
On the server side I added three things for this post: reviews (a Review entity and a group DataLoader), order status (an OrderStatus enum, a mutation that advances the status and a subscription) and @defer support. I show them in the relevant sections.
Typed Operations with GraphQL Code Generator
Handwritten TypeScript types will drift from the schema sooner or later. The code generator reads the schema and the operations in your code and produces the result and variable types for each operation. I pulled the schema from the server with /graphql?sdl into a schema.graphql file; it lives in the client repo so the build doesn't depend on the server:
import type { CodegenConfig } from '@graphql-codegen/cli';
import { addTypenameSelectionDocumentTransform } from '@graphql-codegen/client-preset';
const config: CodegenConfig = {
schema: 'schema.graphql',
documents: ['src/app/**/*.ts'],
ignoreNoDocuments: false,
generates: {
'src/gql/': {
preset: 'client',
// Apollo's cache needs __typename; it must be in the persisted document too
documentTransforms: [addTypenameSelectionDocumentTransform],
presetConfig: {
fragmentMasking: false,
persistedDocuments: { hashAlgorithm: 'sha256' },
},
config: {
useTypeImports: true,
enumsAsTypes: true,
scalars: { Decimal: 'number', DateTime: 'string' },
},
},
},
};
export default config;
Operations are written next to the component with the generated graphql() function. Each component defines what it needs as a fragment; this is the layout we mentioned in the series' GraphQL fundamentals post:
export const ReviewItem = graphql(`
fragment ReviewItem on Review {
id
rating
text
}
`);
@Component({
selector: 'app-review-list',
template: `
<ul>
@for (review of reviews(); track review.id) {
<li>{{ review.rating }}/5 · {{ review.text }}</li>
}
</ul>
`,
})
export class ReviewList {
readonly reviews = input.required<ReviewItemFragment[]>();
}
const BookDetailQuery = graphql(`
query BookDetail($id: Int!) {
book(id: $id) {
id
title
price
author {
name
}
... @defer(label: "reviews") {
reviews(first: 2) {
...ReviewItem
}
}
}
}
`);
Running npx graphql-codegen produced graphql.ts, gql.ts and persisted-documents.json under src/gql/. The type generated for BookDetailQuery understands @defer as well: the reviews are either there or not there at all.
export type BookDetailQuery = { book: { id: number, title: string, price: number, author: { name: string } | null } & ({ reviews: Array<{ id: number, rating: number, text: string }> } | { reviews?: never }) | null };
When you rename book.title in the schema, the Angular build stops with a compile error; you don't see an empty title at runtime. fragmentMasking: false is a deliberate simplification; the client preset's fragment masking feature forces a component to see only the fields in its own fragment, which is valuable in large teams but would distract from this post's topic.
Binding with Signals and dataState
Apollo Angular delivers results as RxJS (Reactive Extensions for JavaScript) observables; on the Angular side I turn them into signals with toSignal:
export class BookDetail {
private readonly apollo = inject(Apollo);
readonly bookId = input.required<number>();
readonly result = toSignal(
toObservable(this.bookId).pipe(
switchMap((id) => this.apollo.watchQuery({ query: BookDetailQuery, variables: { id } }).valueChanges),
),
);
// streaming: the first part arrived, the @defer part is on its way; complete: everything arrived
readonly reviews = computed(() => {
const result = this.result();
return result?.dataState === 'complete' ? result.data.book?.reviews : undefined;
});
}
@let book = result()?.data?.book;
@if (result()?.error; as error) {
<p class="error">{{ error.message }}</p>
}
@if (book) {
<h2>{{ book.title }}</h2>
<p>{{ book.author?.name }} · {{ book.price }} TL</p>
@if (reviews(); as reviews) {
<app-review-list [reviews]="reviews" />
} @else {
<p>Loading reviews…</p>
}
}
Why the reviews computed? In my first attempt I used book.reviews directly in the template and the build stopped: Type '... | DeepPartialObject<{ id: number; rating: number; text: string; }>[]' is not assignable to type 'ReviewItemFragment[]'. Apollo Client 4 adds a dataState to every result: empty, partial, streaming or complete. The type system narrows accordingly; only in the complete state is data the full type. A query with @defer is in the streaming state after the first part and becomes complete after the last one.
On the error side, Apollo Client's default errorPolicy is none: if the response contains a GraphQL error, data is dropped and error is set. On screens where you want to show the partial data we saw in the series' earlier posts, errorPolicy: 'all' gives you both data and error; you don't turn the whole book page into an error screen because the stock badge is missing.
Normalized Cache and Consistency After Mutations
Apollo's InMemoryCache doesn't store responses as they are; it writes each object to a single place under a key made of its __typename and id (like Order:1). Even if the same order shows up in two different queries, there's one copy of it in the cache. The order status component:
const OrderQuery = graphql(`
query Order($id: Int!) {
order(id: $id) {
id
status
total
}
}
`);
const AdvanceOrderStatus = graphql(`
mutation AdvanceOrderStatus($orderId: Int!) {
advanceOrderStatus(input: { orderId: $orderId }) {
order {
id
status
}
}
}
`);
advance(): void {
this.apollo.mutate({ mutation: AdvanceOrderStatus, variables: { orderId: this.orderId() } }).subscribe();
}
I opened the page with Playwright and clicked the button. The network traffic and the screen:
[ 76 ms] POST Order
[ 1595 ms] ORDER Order #1: PLACED | Advance
[ 1621 ms] POST AdvanceOrderStatus
[ 2426 ms] ORDER Order #1: PACKED | Advance | PACKED
After the mutation the Order query wasn't sent again, yet the status on screen changed. Because the mutation's response carried id and status, Apollo updated the Order:1 record, and every query watching that record re-rendered. The rule: in a mutation's payload, ask for the id of the object you changed and the fields that changed. For mutations that add a new item to a list, Apollo can't know which list to add it to; there you need cache.modify in an update callback, or refetchQueries.
The default fetchPolicy of watchQuery is cache-first: if the data is fully in the cache, it doesn't go to the network. For frequently changing data like stock, cache-and-network shows the cache first and refreshes in the background; network-only doesn't read the cache but still writes to it.
Subscriptions: graphql-transport-ws, SSE and SignalR
The server side is the same topic setup as in the series' GraphQL fundamentals post:
public sealed record OrderStatusChange(int OrderId, OrderStatus Status, DateTimeOffset ChangedAt);
[MutationType]
public static partial class OrderStatusMutations
{
public static async Task<Order?> AdvanceOrderStatusAsync(
int orderId,
BookstoreContext db,
ITopicEventSender sender,
CancellationToken cancellationToken)
{
Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
if (order is null || order.Status == OrderStatus.Delivered)
{
return order;
}
order.Status += 1;
await db.SaveChangesAsync(cancellationToken);
await sender.SendAsync(
$"order-status/{orderId}",
new OrderStatusChange(orderId, order.Status, DateTimeOffset.UtcNow),
cancellationToken);
return order;
}
}
[SubscriptionType]
public static partial class OrderSubscriptions
{
[Subscribe]
[Topic("order-status/{orderId}")]
public static OrderStatusChange OnOrderStatusChanged(int orderId, [EventMessage] OrderStatusChange change) =>
change;
}
Program.cs needs .AddInMemorySubscriptions() and app.UseWebSockets(). The in-memory provider only works on a single instance: if the mutation lands on server A and the subscription on server B, the event is lost. With several instances you need the HotChocolate.Subscriptions.Redis package and AddRedisSubscriptions to distribute events through Redis.
On the first attempt the application didn't start: `SHİPPED` is not a valid GraphQL name. The Turkish capital İ problem we hit in HotChocolate.Caching in the previous post showed up this time in our own OrderStatus.Shipped value; I got past it the same way, by pinning the application's culture to CultureInfo.InvariantCulture.
On the client, subscriptions go over WebSocket and everything else over HTTP. Apollo's GraphQLWsLink uses the graphql-ws library, that is, the graphql-transport-ws subprotocol:
// subscriptions over WebSocket, everything else over HTTP
const link = ApolloLink.split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
},
persisted.concat(ws),
persisted.concat(http),
);
[ 178 ms] ws> {"type":"connection_init"}
[ 206 ms] ws< {"type":"connection_ack"}
[ 206 ms] ws> {"id":"c4c3eb63-...","type":"subscribe","payload":{"variables":{"orderId":1},"operationName":"OrderStatus", ...}}
[ 1746 ms] POST AdvanceOrderStatus
[ 1772 ms] ws< {"id":"c4c3eb63-...","type":"next","payload":{"data":{"onOrderStatusChanged":{"orderId":1,"status":"DELIVERED", ...}}}}
Hot Chocolate also offers the same subscription over SSE; we tried it with curl in the series' GraphQL fundamentals post. In the browser it can be used with the graphql-sse library, and it helps where corporate proxies block WebSockets. When SignalR instead of a GraphQL subscription? The map from the first post of the Real-time Applications series applies here too: if the client already speaks GraphQL and the event corresponds to a type in the schema, a subscription is natural. For high-frequency, bidirectional traffic like chat, collaborative editing or games, or when there are clients that don't use GraphQL, SignalR is the better fit.
@defer: Incremental Delivery v0.2
I delayed the reviews by 800 ms as if they came from a slow external service. Without @defer the whole page waits for the slowest field. In Hot Chocolate @defer is off by default; on the first attempt I got The specified directive `defer` is not supported by the current schema. and turned it on with .ModifyOptions(o => o.EnableDefer = true). With curl:
[ 198 ms] {"data":{"book":{"title":"Distributed Systems Vol. 1","author":{"name":"Ada Byron"}}},"pending":[{"id":"2","path":["book"],"label":"reviews"}],"hasNext":true}
[ 1004 ms] {"incremental":[{"id":"2","data":{"reviews":[{"rating":5,"text":"Great chapter on retries."},{"rating":5,"text":"A bit long."}]}}],"completed":[{"id":"2"}],"hasNext":false}
The response is multipart/mixed, in two parts. The first part brings the book and the author at 198 ms and says, with pending, "a part with id 2 is on its way"; the second adds the reviews 800 ms later with incremental and closes it with completed. This is the incremental delivery v0.2 format that Hot Chocolate 16 uses by default; in v0.1 the parts were matched by path. @defer and @stream are still not in the GraphQL specification; they didn't make it into the September 2025 edition and the wire format keeps evolving in the working group. That's why the client and the server must speak the same format.
In Apollo Client 4 this is the job of the incrementalHandler. Defer20220824Handler reads the old format and GraphQL17Alpha9Handler the new one with pending/completed; with the second one selected, Apollo sent the request with Accept: multipart/mixed;incrementalSpec=v0.2,application/graphql-response+json,application/json;q=0.9.
Still, on the first attempt the screen only showed Http failure during parsing for http://localhost:4210/graphql. Apollo Angular's own HttpLink sends requests with Angular's HttpClient, and HttpClient can't parse a multipart/mixed response. Switching to Apollo Client's fetch-based HttpLink made it work:
[ 389 ms] TEXT Distributed Systems Vol. 1 | | Ada Byron · 155 TL | | Loading reviews…
[ 1594 ms] TEXT Distributed Systems Vol. 1 | | Ada Byron · 155 TL | | 5/5 · Great chapter on retries. | 5/5 · A bit long.
This has a cost: the fetch-based link doesn't go through Angular's HTTP interceptors. If you add tokens or log errors in an interceptor, you have to write the same thing as an ApolloLink. In an application that doesn't use @defer, staying with Apollo Angular's HttpLink has less friction.
The Persisted Operation Manifest
In the previous post we configured the server to accept only persisted operations in production. Codegen's persistedDocuments setting computes the SHA-256 (Secure Hash Algorithm 256) hash of every operation, writes it to persisted-documents.json and adds __meta__.hash to every generated document. On the server I matched the hash algorithm: .AddSha256DocumentHashProvider(HashFormat.Hex). A small script in the deploy pipeline writes the manifest into the server's store:
import json, sys, pathlib
# usage: write-persisted.py persisted-documents.json target-dir
docs = json.load(open(sys.argv[1]))
out = pathlib.Path(sys.argv[2]); out.mkdir(exist_ok=True)
for key, text in docs.items():
(out / f"{key.split(':', 1)[1]}.graphql").write_text(text, encoding="utf-8")
print(len(docs), "documents")
On the client, Apollo's PersistedQueryLink sends the hash instead of the query text. We don't compute the hash in the browser; we use the value codegen produced at build time:
// send the hash codegen computed at build time instead of the query text
const persisted = new PersistedQueryLink({
generateHash: (document) => (document as { __meta__?: { hash: string } }).__meta__!.hash.replace('sha256:', ''),
});
const ws = new GraphQLWsLink(
createClient({
url: `ws://${location.host}/graphql`,
// send only the hash over WebSocket too; the server already has the query text
jsonMessageReplacer: (key, value) =>
key === 'payload' && value?.extensions?.persistedQuery ? { ...value, query: undefined } : value,
}),
);
In this post's experiments Hot Chocolate accepted all three shapes: {"id": "<hash>"}, {"documentId": "<hash>"} and the {"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "<hash>"}}} Apollo sends. When I sent documentId with the sha256: prefix, I got "The operation ID has an invalid format." (HC0012); the prefix has to go. The HTTP side worked on the first try. The WebSocket side didn't: even when it goes through the persisted link, GraphQLWsLink also puts the query text in the payload, and the server rejected the subscription with Only persisted operations are allowed. After removing query from the payload with graphql-ws's jsonMessageReplacer option, the subscription opened with the hash too.
The answer to the opening question is here as well. The first version of the persisted documents had no __typename. Apollo Client automatically adds the __typename its cache needs to the queries it sends; but now it was the file on the server, not Apollo, that decided the query text, and that file had no __typename. Without __typename in the response, Apollo couldn't tell which type the ...ReviewItem fragment belonged to and left the review fields empty; and since the Order returned by the mutation couldn't be normalized, Order:1 in the cache wasn't updated. After adding addTypenameSelectionDocumentTransform to the codegen config and regenerating the manifest, everything was fixed:
"sha256:938671321c...": "fragment ReviewItem on Review { __typename id rating text } query BookDetail($id: Int!) { __typename book(id: $id) { __typename author { __typename name } id price title ... @defer(label: \"reviews\") { __typename reviews(first: 2) { __typename ...ReviewItem } } } }"
[ 1618 ms] TEXT Distributed Systems Vol. 1 | | Ada Byron · 155 TL | | 5/5 · Great chapter on retries. | 5/5 · A bit long.
[ 1688 ms] ws< {"id":"7ad470c4-...","type":"next","payload":{"data":{"onOrderStatusChanged":{"__typename":"OrderStatusChange", ...,"status":"PACKED"}}}}
[ 2470 ms] ORDER Order #1: PACKED | Advance | PACKED
This last output was taken in the Production environment, against the server that accepts only persisted operations: the query, the mutation, @defer and the subscription all work with the same manifest.
Production Notes
Deploy the manifest to the server before the client. If a new client version sends a hash the server doesn't know yet, the request is rejected with HC0020. Keep the manifest cumulative: old client versions live on in browsers for a while, and their hashes must stay valid.
In production the WebSocket address must be wss://, and subscriptions must go through authorization too. You need to send the token with graphql-ws's connectionParams when the connection opens and validate it on the server; the browser can't add custom headers to a WebSocket.
Keep the schema file in the client repo and run codegen in CI. If the server's schema changes while the client's stays old, the generated types lie; that's why it's useful to wire the previous post's schema diff into the client's build too.
Use @defer based on measurements. Every deferred part means a separate execution plan and a separate network part. It pays off for slow fields in a secondary part of the screen; deferring fast fields only adds complexity.
Common Pitfalls
Forgetting __typename in persisted documents. Apollo's normalized cache and fragment matching depend on it; without it, data silently looks empty.
Trying @defer through Angular's HttpClient. It can't parse multipart/mixed; you need a fetch-based link.
Not asking for the id in a mutation payload. The cache can't find the changed object and the screen keeps showing old data.
Scaling to several instances with the in-memory subscription provider. Events only reach subscribers on the instance where they were published.
Forgetting to run codegen on every schema change. If you commit the generated files, add a "run codegen, is the diff empty" check in CI.
A Small Experiment
★☆☆ Remove @defer from the BookDetail query and compare the time to first content with Playwright or the browser's Network tab.
★★☆ Add an addReview mutation and add the new review to the list on the book detail page without refetchQueries, using cache.modify in the update callback. Count how many requests go out after the mutation.
★★★ Add authorization to the subscription connection: send the token with connectionParams, validate it with a socket session interceptor in Hot Chocolate, and test that users can only listen to the status of their own orders.
We've built gRPC and GraphQL end to end, each on its own. In the next post we bring them all together: we measure the same Bookstore scenarios with REST (Representational State Transfer), gRPC and GraphQL using k6 and decide which one to choose in which situation, with numbers.