Composable Storefront SSR Incidents: Diagnose Timeouts, Hangs, and Memory Restarts
A production runbook for Composable Storefront SSR timeouts, hung renders, Node heap growth, cache failures, and safe remediation.
Priya Ramanathan
SAP Commerce Storefront & Frontend Expert
Composable Storefront and Spartacus, SSR, OCC optimization, and Angular architecture.
An SSR incident is rarely “a Node problem.” A customer request crosses CDN, Apache/Nginx, the JavaScript storefront Node process, OCC, Commerce services, Solr, database, and often external APIs. The visible symptom—blank content, a 504, a CSR fallback, a CPU spike, or a PM2 restart—only becomes actionable when you establish which layer failed and which request shape triggered it.
This guide targets the optimized SSR engine in SAP Commerce Cloud, composable storefront 2211-era applications. Names and defaults are version-sensitive; inspect the exact server.ts, package.json, and library release deployed to the affected environment before changing any setting. It complements Composable Storefront Engineering, which covers the coding practices that prevent many of these failures.
Start with a symptom-to-evidence map#
Do not raise memory, concurrency, or timeout values from the symptom alone. Each change can make a failing request consume resources for longer and can convert a recoverable fallback into a pod restart.
| Symptom | First evidence | Likely layer | Safe first action |
|---|---|---|---|
| The document contains only the app shell or behaves like CSR | Initial document response and browser Network Preview, not only the Elements tab | SSR timeout/fallback, excluded route, rendering strategy | Prove whether the response was SSR or CSR; correlate it with SSR logs. |
Rendering ... was not able to complete or SSR rendering exceeded timeout | Exact timestamp, URL, render key, duration, concurrent request count | Slow downstream dependency or a render that never stabilizes | Trace the request through Node and OCC before changing timeouts. |
| 502/504 on a route containing a custom CMS component | Node stack trace and the first server-side exception | Browser-global access, direct DOM code, private API denial, unhandled promise | Reproduce SSR locally or in a lower environment and isolate the component. |
| JS App restarts / saw-tooth heap / rising RSS | Dynatrace Node heap, process restart events, three comparable heap snapshots | Retained subscriptions/listeners/timers, runaway render, in-process cache | Find retaining paths; reduce pressure only after eliminating the retention source. |
| One page drives CPU high after a CMS change | URL, CMS payload, pages/components response relationship, deployed storefront version | Cache inconsistency or a client navigation loop | Roll back/revert content if necessary; apply the version-specific product fix. |
| SSR gets a 403 while browser navigation works | Node-to-OCC request plus API/IP allow-list policy | Private endpoint filter / CDN / API configuration | Allow the SSR source through the approved filter; do not expose private APIs publicly. |
An incident ticket should start with a single sentence that pins the failure: “At 10:14 UTC, anonymous GET /en/USD/p/12345 from the CDN received CSR fallback after 3,000 ms; Node spent 2,820 ms waiting on /occ/.../products/...; no process restart occurred.” That is diagnosable. “SSR is slow” is not.
Prove the rendering mode before debugging it#
The browser DOM after hydration cannot prove SSR. Angular can populate an empty application shell milliseconds later. Check the initial document response.
# Run only against an authorized lower environment or a production-safe public URL.
# Save headers and body separately so they can be attached to an incident.
curl --silent --show-error --compressed \
-D /tmp/storefront.headers \
-o /tmp/storefront.html \
-A 'Mozilla/5.0 (SSR verification)' \
'https://storefront.example.test/en/USD/p/12345'
grep -E '<app-root|cx-page-layout|product-summary|data-cms-id' /tmp/storefront.html
sed -n '1,40p' /tmp/storefront.headers
Then use browser DevTools:
- Disable cache and reload with the Network panel open.
- Select the document request, not a later XHR/fetch request.
- Read the Preview or raw response body. Look for meaningful content inside
app-rootthat is expected for that route. - Compare browser and crawler user agents only if your rendering strategy intentionally distinguishes them.
SAP's SSR verification guidance describes this distinction: a non-empty app-root in the initial response is the meaningful check, and partial SSR is possible. Record the concrete component or product text you expect; app-root alone can be non-empty even when the important route region fell back.
Draw the request path, then correlate one timestamp#
The fastest way to lose hours is to investigate the JavaScript service in isolation. Use this topology in every incident and identify the owning team for each hop:
browser / bot
-> CDN / edge cache
-> Apache / Nginx
-> JS Storefront SSR (Node, optimized engine)
-> OCC endpoint(s)
-> Commerce services / Solr / database / remote integrations
-> Node emits HTML or CSR fallback
-> CDN returns the document
For one representative request, collect:
| Hop | Evidence | What it distinguishes |
|---|---|---|
| CDN | URL, cache status/key, edge duration, request ID | Cache miss, blocked source, origin slowness, variation by locale/site. |
| Proxy | Access-log timestamp, upstream status/duration | Proxy/network failure versus a slow Node response. |
| Node SSR | Optimized-engine logs, CPU/RSS, render queue/concurrency state | Queue fallback, a render exceeding timeout, or a process exception. |
| OCC | Endpoint, fields, response duration/status, correlation ID | A frontend render waiting on Commerce rather than using CPU itself. |
| Commerce | Dynatrace PurePath/service flow, Solr/DB/integration spans | The actual slow service or downstream dependency. |
SAP KBA 3130505 specifically recommends following the service flow across client/CDN, Apache/Nginx, Node.js SSR, OCC/API, and database rather than treating SSR CPU or memory as standalone evidence. In Dynatrace, inspect both the Node service and the OCC service: a Node CPU spike with long downstream waits is a different remediation from a CPU-bound Angular render.
Know the optimized SSR engine’s safety valves#
The optimized engine is a back-pressure mechanism, not a magic performance setting. In the documented Commerce 2211 defaults, it queues renders and chooses CSR when it cannot safely accept more distinct work.
| Option | Documented default | Operational meaning | Dangerous “fix” |
|---|---|---|---|
cacheSize | 3000 | Entry-count bound for the in-process rendered-page cache. | Increasing it to hide slow origin responses. |
cacheSizeMemory | 800_000_000 | Approximate in-memory rendered-page cache budget. | Treating it as a heap-leak cure. |
concurrency | 10 | Maximum simultaneous actual renders per Node replica. | Raising it until CPU saturation turns every render slow. |
timeout | 3_000 ms | Time after which a request can fall back to CSR. | Raising it before measuring slow OCC/external calls. |
forcedSsrTimeout | 60_000 ms | Safety ceiling for ALWAYS_SSR requests. | Using ALWAYS_SSR on checkout/account routes without a hard rationale. |
maxRenderTime | 300_000 ms | Logs a stuck render and releases its concurrency slot; it does not guarantee the underlying work is freed. | Assuming the warning means memory has been released. |
reuseCurrentRendering | true | Same render key can share one actual render while each requester honors its own timeout. | Load-testing only one repeated URL and concluding capacity is high. |
The source of truth for these semantics is SAP's SSR optimization reference. Confirm defaults in the installed release; SAP evolves them.
Configure only the setting you have a measured reason to change. This is a 2211-style server setup, not a copy-and-paste promise for every major version:
import {
NgExpressEngineDecorator,
ngExpressEngine as engine,
SsrOptimizationOptions,
} from '@spartacus/setup/ssr';
const ssrOptions: SsrOptimizationOptions = {
// Derived from an observed P95 render time, CPU capacity, and a defined fallback policy.
timeout: 4_000,
maxRenderTime: 30_000,
// Keep default rendering strategy behavior unless an explicit policy requires otherwise.
};
const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions);
server.engine('html', ngExpressEngine({ bootstrap: AppServerModule }));
maxRenderTime must remain greater than timeout and forcedSsrTimeout where that option is used. Lowering it creates earlier diagnostic signals and releases the queue slot, but it does not cancel a stuck promise, timer, or HTTP call. Fix the render source.
Isolate a slow or never-ending SSR render#
Timeout is a diagnostic, not a root cause#
When the document falls back at 3 seconds, determine whether Node was queued, rendering CPU work, or waiting for OCC. Add targeted measurement in a lower environment first. The following interceptor is intentionally narrow and temporary; it logs only the method, URL path, status, and duration—never authorization headers, cookies, body content, cart data, or PII.
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
HttpResponse,
} from '@angular/common/http';
import { isPlatformServer } from '@angular/common';
import { Observable, finalize, tap } from 'rxjs';
@Injectable()
export class SsrOccTimingInterceptor implements HttpInterceptor {
private readonly platformId = inject(PLATFORM_ID);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (!isPlatformServer(this.platformId) || !request.url.includes('/occ/')) {
return next.handle(request);
}
const startedAt = performance.now();
let status = 'network-error';
return next.handle(request).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
status = String(event.status);
}
}),
finalize(() => {
const durationMs = Math.round(performance.now() - startedAt);
console.info('[ssr-occ-timing]', {
method: request.method,
path: new URL(request.url, 'http://placeholder').pathname,
status,
durationMs,
});
}),
);
}
}
Register the interceptor in a non-production test deployment, exercise the exact failing route once, capture the output, then remove it. SAP KBA 3282585 gives a similar diagnostic pattern and is explicit that it is for testing rather than production instrumentation. Long-term timing belongs in approved observability with sampling, correlation, and data-handling controls.
Bound downstream calls at the operation that owns the user experience. This avoids a never-resolving remote dependency holding Angular stability forever.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, retry, throwError, timeout } from 'rxjs';
interface DeliveryPromise {
estimatedDate: string;
available: boolean;
}
class DeliveryPromiseUnavailableError extends Error {
constructor(productCode: string, cause: unknown) {
super(`Delivery promise is unavailable for ${productCode}`);
this.name = 'DeliveryPromiseUnavailableError';
this.cause = cause;
}
}
@Injectable({ providedIn: 'root' })
export class DeliveryPromiseAdapter {
constructor(private readonly http: HttpClient) {}
load(productCode: string): Observable<DeliveryPromise> {
return this.http
.get<DeliveryPromise>(`/occ/v2/electronics-spa/delivery-promises/${productCode}`)
.pipe(
timeout({ each: 1_500 }),
retry({ count: 1, delay: 100 }),
catchError((error) =>
throwError(() => new DeliveryPromiseUnavailableError(productCode, error)),
),
);
}
}
The calling component/facade must decide whether an unavailable delivery promise is a recoverable empty state or an error page. Do not replace every failed OCC request with EMPTY: that may make an SSR request finish, but it can turn a broken commerce journey into silently missing price, stock, or checkout data.
Hunt work that prevents Angular from stabilizing#
The high-risk patterns are simple:
// Never in code reachable during SSR.
setInterval(() => this.refresh(), 5_000);
// Also unbounded: each completion schedules another server-visible task.
const refresh = () => setTimeout(refresh, 5_000);
// A promise/observable that never resolves or completes can hold a render open.
return this.remoteClient.waitForever();
Replace implicit background work with explicit, browser-only, lifecycle-bound behavior:
import { DestroyRef, Injectable, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { WindowRef } from '@spartacus/core';
import { EMPTY, timer } from 'rxjs';
import { exhaustMap } from 'rxjs/operators';
@Injectable()
export class BrowserInventoryRefreshService {
private readonly destroyRef = inject(DestroyRef);
private readonly windowRef = inject(WindowRef);
start(): void {
if (!this.windowRef.isBrowser()) {
return;
}
timer(0, 60_000)
.pipe(
exhaustMap(() => this.refreshInventory()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
private refreshInventory() {
// Return a finite Observable with its own timeout/error policy.
return EMPTY;
}
}
This is an architectural shape, not a recommendation to poll inventory. If a browser refresh is required, own when it starts, what it refreshes, how it stops, and why it never runs on the server. SAP KBA 3283570 calls out permanently scheduled intervals, recursive timeouts, and never-completed HTTP work as common reasons SSR hangs. Its suggested zone-task tools are debugging aids for local/lower environments, not production dependencies.
Investigate Node memory restarts with evidence, not cache tuning#
The classic graph is a saw-tooth heap: memory rises, the JavaScript application restarts, it drops, and then rises again. It can be a leak, an in-process SSR cache filled with too many pages, a burst of renders waiting on slow OCC, or several of those simultaneously.
SAP KBA 3129854 recommends V8 heap analysis and explicitly warns against treating restarts as the root cause. KBA 3130477 describes PM2 restarts when the Node process reaches its configured memory threshold in the SSR container. The investigation order matters:
- Establish slope and restart cadence. Capture process RSS/heap, replica count, render throughput, error rate, and restart timestamps over at least one normal and one failing window.
- Compare three snapshots. Take a baseline after warm-up, one after the suspected traffic/content action, and one just before pressure/restart. A single snapshot only lists allocation; comparison identifies objects that remain retained.
- Follow retaining paths. Look for component instances,
Subscriberchains, event listeners, timers, cached HTML strings, request objects, and closures held from prior SSR app instances. - Reproduce the trigger. Same URL and same anonymous/authenticated context, but do it in a lower environment with a bounded load profile.
- Remove or bound the owner. Unsubscribe/tear down, clear a timer/listener, stop retaining request state, or cap/expire a cache. Repeat the three-snapshot comparison.
- Only then tune capacity. Lower in-process cache/concurrency if they amplify pressure; scale replicas or alter platform memory allocation only after code/content cause is controlled.
Do not make heap dumps from production casually. They can contain customer data and should follow the incident, access-control, retention, and SAP support process for the environment. A local node --inspect session can be valuable for a reproducible lower-environment case, but it is not a production mitigation.
Separate cache incoherence from a memory leak#
Rendered pages, CMS pages responses, CMS components responses, browser navigation state, and CDN content can each have a different lifetime. A content change that invalidates only one layer can produce a route-specific loop even though heap behavior looks abnormal.
Use this sequence when an incident appears immediately after CMS content/catalog sync:
- Capture the exact
/cms/pagesand/cms/componentsresponse bodies for the route, sanitized for incident storage. - Record CMS component UIDs/types in the
pagespayload and verify every reference resolves incomponents. - Compare CDN cache status/key, Node render key, base site, language, currency, catalog version, and preview/SmartEdit context.
- Reproduce after a controlled cache bypass/invalidation in a lower environment; do not purge production broadly before identifying the key scope.
- Check the deployed Composable Storefront version against SAP's known issue history.
SAP KBA 3358788 documents one concrete version-specific case: after a CMS link removal, stale pages and components data could become inconsistent and drive a navigation loop; the permanent remedy is the Composable Storefront fix for the affected 6.3.x line. A local NavigationService override may be a temporary workaround for that exact version, but it is not a universal cache pattern and should be removed once the product fix is deployed.
The optimized SSR engine's in-memory cache is not a CDN. SAP recommends an external cache layer for rendered pages and cautions that server memory is finite. Cache only public responses with a provable variant key. Checkout, account, ASM, preview/SmartEdit, cart, consent, and personalization deserve explicit exclusion or a separate policy.
Load-test the rendering policy you actually operate#
With reuseCurrentRendering: true, 200 simultaneous requests to the same URL can collapse into one real render plus waiters. That is useful behavior in production but an invalid capacity test for a catalog.
// k6-style conceptual test: vary routes and public context intentionally.
// Use authorized lower-environment traffic and realistic cache warm/cold phases.
export default function () {
const product = `P${(__VU * 1000 + __ITER) % 5000}`;
const url = `https://storefront.example.test/en/USD/p/${product}`;
http.get(url, { headers: { 'User-Agent': 'load-test/ssr-capacity' } });
}
Run at least two test shapes and keep their results separate:
| Shape | Purpose | What to record |
|---|---|---|
| Same hot URL in parallel | Validates render sharing and cache behavior | First render duration, waiter fallbacks, CDN hit rate. |
| Many distinct public URLs | Validates real render concurrency and downstream OCC capacity | SSR versus CSR ratio, queue depth, Node CPU/heap, OCC P95/P99, error rate. |
| Content change / cold cache | Validates invalidation and cache-key correctness | CMS page/component consistency, cache fill rate, no loop/regression. |
SAP's SSR reference explicitly says varied URLs are required to test real rendering capacity when render reuse is enabled. Set abort thresholds first: an “experiment” that drives a shared lower environment into repeated restarts is not useful evidence.
Close an incident with a reproducible evidence pack#
Before declaring the incident resolved, attach these artifacts (redacted where necessary):
| Artifact | Purpose |
|---|---|
| Request matrix | URL, public context, user agent, status, SSR/CSR result, start/end timestamp, request/correlation ID. |
| Layer trace | CDN/proxy/Node/OCC/Commerce timing for one good and one bad request. |
| Engine configuration | Deployed values of SSR options and rendering strategy—not assumptions from source control. |
| Heap/restart evidence | Trend, restart events, and comparison summary of heap snapshots if memory was involved. |
| Change record | CMS sync, code/config deploy, cache change, endpoint change, or load test immediately preceding failure. |
| Verification | Cold and warm route tests, hydration check, and an explicit proof that the suspected defect no longer appears. |
The correct permanent fix may be an OCC query optimization, a bounded observable, a browser guard, an SAP patch level, a cache invalidation correction, or a policy change. It is almost never “increase every SSR limit.”