Background and Architectural Context

How Doric Works in a Nutshell

Doric executes TypeScript/TSX code inside an embedded JavaScript runtime and translates virtual views into native widgets via a lightweight bridge. Your code manipulates a declarative view tree; Doric computes diffs and issues granular native updates. The approach provides a 'write once, render natively' path without shipping a full browser. In large apps, however, the bridge and rendering pipeline become a first-class subsystem with its own performance envelope, memory profile, and lifecycle concerns.

Common Enterprise Integration Patterns

  • Embedded screens in host apps: Doric views are mounted inside legacy Android Activities or iOS ViewControllers. Challenges include conflicting lifecycles, navigation stacks, and window insets.
  • Feature modules: Doric views are delivered as remote or local bundles and updated out-of-band from the host app. Pitfalls include bundle cache invalidation, signature verification, and version skew between JS and native devkits.
  • Hybrid rendering: Mixed screens that combine Doric widgets with host-native fragments or SwiftUI/UIKit views can suffer from measurement conflicts and z-order or gesture-handling quirks.

System Model and Failure Modes

Key Components to Observe

  • JS Engine: The embedded runtime (commonly JavaScriptCore on iOS; VMs such as V8/QuickJS or platform-provided engines on Android/Qt/Web) runs your business logic and layout code. CPU pressure or long GC pauses manifest as FPS drops and input latency.
  • Bridge: The communication layer moves batched mutations, events, and data across JS↔native boundaries. Excessive chatter or large payloads saturate queues and increase latency.
  • Renderer: Native widget creation, layout, and draw cycles. Incorrect sizing policies or frequent remeasurements cause jank and battery drain.
  • Assets: Images, fonts, and local resources. Inefficient decoding/resizing or unbounded caches cause memory spikes and slow startup.
  • Bundles: TS/TSX compiled output, often with sourcemaps. Corruption, partial downloads, or stale caches lead to mysterious runtime errors.

High-Impact Failure Categories

  • Cold start regression: App or screen takes multiple seconds to appear due to synchronous bundle IO, eager module initialization, or certificate checks on first run.
  • Event storming: High-frequency scroll/gesture events crossing the bridge each frame, starving the JS loop and delaying UI thread work.
  • Layout thrash: Rapid measure→mutate→measure loops due to dependent constraints or code that mutates layout on layout callbacks.
  • Memory pressure: Large image bitmaps, unreleased page caches, retained closures, or reference cycles between JS and native objects.
  • Lifecycle drift: Mounted views not unmounted on host navigation, causing hidden yet alive JS timers, listeners, and animations.
  • Version skew: Mismatch between the Doric JS package and native devkits (Android/iOS/Qt), leading to missing APIs and subtle crashes.

Diagnostics: From Symptom to Signal

Instrument the JS Runtime

Capture event loop health and GC pauses. Add timing probes around render batches, diff computations, and business logic. A minimal probe can reveal if jank originates in JS or native layers.

function timeit(label, f) {
  const t0 = Date.now();
  const r = f();
  const t1 = Date.now();
  console.log(`[perf] ${label}: ${t1 - t0}ms`);
  return r;
}
// Example: wrap a render pass
timeit("render", () => rootView.alignment = "center");

Track max/min/avg frame times for critical interactions (screen open, list scroll, typing). If p95 exceeds ~16ms on 60Hz displays, expect visible stutter.

Profile Bridge Traffic

Log per-frame mutation counts and payload sizes. A spike during fast scrolling often identifies chatty properties updated redundantly (e.g., onScroll firing for every pixel).

// Pseudocode; adjust to Doric logging APIs
const origApply = bridge.applyMutations;
bridge.applyMutations = (batch) => {
  metrics.count += batch.length;
  metrics.bytes += JSON.stringify(batch).length;
  return origApply(batch);
};

Find Layout Hotspots

Enable native layout profiling. On Android, use Layout Inspector and Systrace for measure/layout passes; on iOS, use InstrumentsCore Animation to visualize layout and compositing. Look for repeated measurement of the same subtree each frame.

Isolate Asset Pressure

Dump peak heap snapshots on representative devices. Cross-check decoded bitmap sizes against screen density and container bounds to catch oversize images rendered at thumbnail sizes.

// Android launch args
adb shell am start -n com.example/.MainActivity \
  --es doric.profiler.enabled true

Verify Bundle Integrity and Caching

Log bundle URL, checksum, and last-modified headers. Instrument code paths that choose between assets://, file://, and https:// sources. Ensure sourcemaps align with deployed minified bundles for actionable stack traces.

Troubleshooting Playbooks

1) Slow Cold Start or Screen Mount

Symptoms: First Doric screen takes 1–5s to appear, occasionally worse on low-end devices or after clearing cache.

Likely root causes: Synchronous bundle IO on UI thread; eager registration of all custom widgets; expensive global initializers; TTF font parsing during first render; SSL handshake and signature verification for remote bundles.

Diagnostics: Split timings into phases: host init, JS engine warm-up, bundle load/validate, first render commit. On Android, capture a Traceview/Perfetto trace; on iOS, profile with Time Profiler.

Fixes:

  • Prewarm the JS engine at app launch on a background thread, then lazily mount views.
  • Defer non-critical widget registration until first use; register above-the-fold components only.
  • Move bundle IO off the main thread; memory-map large bundles to reduce copy time.
  • Preload and cache fonts; prefer WOFF2 where supported to reduce parse cost; subset font glyphs when possible.
  • Stagger network validations by caching prior trust decisions and using short-lived signed manifests to avoid full checksum scans on every launch.
// Android: offload bundle read and prewarm JS
Executors.newSingleThreadExecutor().execute(() -> {
  DoricEngine.prewarm();
  byte[] bundle = Files.readAllBytes(bundlePath);
  mainHandler.post(() -> doricView.mount(bundle));
});

2) Jank During Scroll or Gesture

Symptoms: Scroll hitches, delayed taps, low FPS. Stutter increases with item count or when animated headers are present.

Likely root causes: High-frequency onScroll events crossing the bridge; style mutations in response to scroll (e.g., per-pixel opacity/transform changes done on JS thread); list cell re-creation rather than recycling; imagery decoded on main thread.

Diagnostics: Count mutations per 16ms window; flag any that exceed a threshold (e.g., 200 property updates). Audit if mutations affect layout (width/height) vs. transform-only. Profile image decode time on UI thread.

Fixes:

  • Throttle or sample scroll events; compute animations with native compositors where possible (GPU-friendly transforms).
  • Adopt cell recycling for long lists; pre-measure predictable item sizes.
  • Move image decode/resize to background threads; decode to destination size, not source dimensions.
  • Batch mutations: coalesce small updates into one commit per frame.
// Throttle scroll-driven updates
let lastTs = 0;
function onScroll(e) {
  const now = Date.now();
  if (now - lastTs < 50) return; // ~20fps for logic
  lastTs = now;
  requestAnimationFrame(() => header.translationY = e.offsetY * 0.5);
}

3) Memory Leaks or OOM on Long Sessions

Symptoms: Memory grows over hours of use; backgrounding/foregrounding accelerates growth; low-memory kills or OOMs increase.

Likely root causes: Unbounded image caches; retained JS closures referencing native views; timers/intervals left running after view unmount; event listeners attached to global singletons; orphaned offscreen canvases.

Diagnostics: Periodic heap snapshots; diff to find growing types. On iOS, check Allocations and Leaks; on Android, use Android Studio Memory Profiler and LeakCanary.

Fixes:

  • Establish a universal unmount hook that clears timers, unsubscribes listeners, and nulls references to views.
  • Implement LRU caches with strict caps (memory & count). Evict on memory warnings and background events.
  • Avoid keeping closures that capture large data; pass minimal data or IDs and re-fetch as needed.
  • Adopt weak references where supported for cross-layer listener registries.
// A robust unmount hook
function teardown(view) {
  view.listeners?.forEach(unsub => unsub());
  view.listeners = [];
  for (const t of view.timers || []) clearInterval(t);
  view.timers = [];
  view.children = []; // let GC collect
}
rootView.onUnmount = () => teardown(rootView);

4) Webhook/Callback Timeouts and Bridge Deadlocks

Symptoms: Background sync or remote config callbacks never resolve; UI reflects stale state; logs show pending promises forever.

Likely root causes: JS promises relying on native responses that are never delivered due to lifecycle cancellation; bridge queue paused during navigation transitions; long-running synchronous native work blocks response.

Diagnostics: Add deadline timers to all cross-bridge RPCs. Emit correlation IDs per request/response. Track per-RPC latency distributions and timeout percentages.

Fixes:

  • Enforce strict timeouts with cancelation semantics and visible fallbacks.
  • Move heavy native operations off the main thread; respond asynchronously.
  • Ensure bridge resumes on lifecycle events; flush pending queues when views reappear.
// RPC with timeout and cancelation
function callNative(method, payload, {timeoutMs = 5000} = {}) {
  const id = genId();
  const p = new Promise((resolve, reject) => {
    const t = setTimeout(() => reject(new Error("timeout")), timeoutMs);
    bridge.once(`resp:${id}`, (resp) => { clearTimeout(t); resolve(resp); });
    bridge.emit("req", {id, method, payload});
  });
  return p;
}

5) Bundle Loading, Integrity, and Staging Drift

Symptoms: A subset of devices run older UI even after release; stack traces do not match source; occasional "Unexpected token" errors only in production.

Likely root causes: Multiple bundle locations with conflicting precedence; CDN edge caching without cache-busting; sourcemaps not uploaded to crash backend; minifier incompatibility with the embedded JS engine.

Diagnostics: Emit bundle metadata at runtime (commit, build time, checksum). Compare staging vs. production headers and "Cache-Control" policies. Validate that the minifier target matches the JS engine's supported syntax.

Fixes:

  • Adopt a signed manifest with version and content hash; always prefer the highest semver that validates.
  • Canonicalize bundle precedence: local override for debug, remote for prod, and asset fallback only on hard failure.
  • Configure cache-busting via immutable URLs (content hashes) and long TTLs; avoid mutable "latest.js".
  • Ensure the build transpiles to a conservative target (e.g., ES5/ES2016) compatible with the embedded engine.
// Example: manifest-first bundle resolution
async function resolveBundle() {
  const m = await fetch("https://cdn.example/app/manifest.json");
  const {version, url, sha256} = await m.json();
  const buf = await fetch(url).then(r => r.arrayBuffer());
  if (!verifySha256(buf, sha256)) throw new Error("bad hash");
  return buf;
}

6) Gesture Conflicts and Z-Order Oddities

Symptoms: Taps or swipes randomly ignored; overlapping views behave differently on Android vs. iOS; ripple/press effects appear behind other components.

Likely root causes: Mixed gesture recognizers (Doric vs. host app) not coordinated; incorrect hit-test areas after transforms; platform-specific default z-index behavior.

Diagnostics: Enable hit-test visualization; log gesture recognizer states; simplify to a minimal reproducible case and compare both platforms.

Fixes:

  • Unify gesture dispatch by delegating edge swipes and back-swipe to the host; reserve content gestures for Doric.
  • Set explicit elevation/z-index on overlapping widgets; avoid relying on insertion order.
  • Normalize transform origin and hit-box calculation after scaling/rotations.

7) Threading and Async Pitfalls

Symptoms: Rare crashes under load; warnings about UI updates off the main thread; state races when rapidly mounting/unmounting views.

Likely root causes: Native modules updating views from background threads; JS callbacks invoked after teardown; not marshaling to the correct dispatcher.

Diagnostics: Annotate all native entry points with thread context in logs; add "isMounted" guards before applying mutations.

Fixes:

  • Centralize dispatch to UI/Main threads for any view mutation.
  • Introduce lifecyle-aware scopes so callbacks after unmount are ignored.
  • Debounce rapid mount/unmount cycles (e.g., during navigation animations).
// iOS: enforce main-thread UI updates
dispatch_async(dispatch_get_main_queue(), ^{
  [view setAlpha:1.0];
});

Deep Dive: Performance Tuning

Reduce Bridge Chatter

  • State colocation: Calculate transient UI state inside native animations to avoid per-frame JS updates.
  • Batch commits: Group mutations per frame via a scheduler; collapse repeated writes to the same property.
  • Event sampling: Downsample noisy events (scroll, drag) and prefer "end" events for expensive work.
// Frame-batched commit queue
const queue = [];
let rafPending = false;
function setProp(node, k, v) {
  queue.push({node, k, v});
  if (!rafPending) {
    rafPending = true;
    requestAnimationFrame(() => {
      bridge.applyMutations(queue.splice(0));
      rafPending = false;
    });
  }
}

Optimize Lists and Grids

  • Use virtualization for long lists; keep only visible items mounted.
  • Precompute row heights where possible; avoid variable-height cells that force reflow on scroll.
  • Pool item views; avoid constructing views during fast scroll regions.

Asset and Image Strategy

  • Adopt a decode pipeline that respects target size and device density; never decode full-resolution images for thumbnail views.
  • Use WebP/AVIF on platforms that support them to save memory and bandwidth.
  • Set strict image cache ceilings (both count and bytes) and evict on memory warnings/background.

Scheduling and Priorities

  • Assign priorities: input>animation>render>network>background parsing. Never allow network callbacks to run at input priority.
  • Yield often during long JS work; break work into micro-tasks to keep the event loop responsive.
// Cooperative yielding for long loops
async function processLarge(items) {
  for (let i = 0; i < items.length; i++) {
    step(items[i]);
    if (i % 200 === 0) await new Promise(r => setTimeout(r));
  }
}

Platform-Specific Gotchas

Android

  • Activity/Fragment lifecycles: Ensure Doric views pause/resume with the host. Stop animations and timers in onPause; release resources in onDestroyView.
  • Back stack: If integrating with Navigation Component, ensure transitions don't duplicate Doric fragments behind the scenes. Use "singleTop" when appropriate.
  • Insets and keyboards: Apply WindowInsets correctly; throttle layout invalidations when soft keyboard toggles.
  • ProGuard/R8: Keep Doric reflection targets and JS-callable classes. Add keep rules to prevent minification from stripping names.
// Keep rules example
-keep class pub.doric.** { *; }
-keepclassmembers class * {
  @pub.doric.Export *;
}

iOS

  • Run loop modes: Timer-based JS work can pause during scroll if the timer is scheduled in default mode. Use common modes where appropriate.
  • Memory pressure: Respond to UIApplicationDidReceiveMemoryWarning by purging caches and snapshots.
  • Background tasks: For background fetches triggering Doric work, request extra time via beginBackgroundTask and end promptly.
// Ensure timers fire during scroll
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

Qt and Desktop Embeds

  • High-DPI scaling can double memory usage if images are not density-aware; supply @2x assets or vector formats.
  • Event loops: ensure JS and Qt event loops cooperate; avoid starvation by pumping both regularly.

Security, Reliability, and Compliance

Secure Bundle Delivery

  • Sign bundles and validate signatures before execution; fail closed if validation fails.
  • Use HTTPS with certificate pinning for manifest and bundle fetches; rotate pins safely.
  • Implement rollback: keep the previous known-good bundle and support atomic swaps.
// Atomic swap of bundles
const next = downloadToTemp();
if (verify(next)) {
  move(tempPath, livePath); // rename is atomic on most FS
} else {
  log("bundle verify failed");
}

Observability

  • Emit structured logs with device info, bundle version, screen ID, and correlation IDs for cross-layer tracing.
  • Wire metrics to your APM (frame time, bridge queue length, mutation batch sizes, memory usage).
  • Capture crashes with minified+symbolicated stacks; upload sourcemaps on deploy.

Resilience Patterns

  • Graceful degradation: present cached content or a light offline screen when bridge/native is unavailable.
  • Backoff and retry for network-bound initializers; show progressive UI.
  • Guardrails: assert on unsupported APIs at startup, not mid-session.

Step-by-Step "Fix Kits"

Fix Kit A: Stabilize a Flaky Screen

  1. Record a baseline: cold/warm mount times, p95 frame time, memory at mount/unmount.
  2. Turn on bridge/mutation logging; identify the top three noisy properties.
  3. Throttle events, batch commits, and move layout-affecting mutations off scroll handlers.
  4. Replace per-item image decode with pre-sized thumbnails and a capped cache.
  5. Ship behind a feature flag; compare metrics to baseline; iterate.

Fix Kit B: Stop Memory Growth Over Time

  1. Add onUnmount teardown hooks to all screens; clear timers/listeners.
  2. Implement a global LRU image cache with a hard byte ceiling; evict on warnings.
  3. Audit large closures; convert to ID-based fetch patterns.
  4. Take periodic heap snapshots on test devices; verify flat memory over multi-hour runs.

Fix Kit C: Make Bundle Updates Bulletproof

  1. Introduce a signed manifest and immutable content-hash URLs.
  2. Display runtime bundle metadata in a hidden debug menu for quick field diagnostics.
  3. Upload sourcemaps per release; verify crash symbolication end-to-end.
  4. Gate new bundles with a staged rollout and fast rollback mechanism.

Best Practices for Long-Term Stability

Architecture

  • Clear boundaries: Keep business logic pure and UI mutations localized. Reduce cross-screen shared state to lower coupling.
  • Compatibility matrix: Maintain a matrix of Doric JS and native devkit versions; upgrade in lockstep with explicit deprecation windows.
  • Feature isolation: Use micro-bundles per feature to minimize cold start and blast radius.

Performance Budgets

  • Set FPS, TTI, and memory budgets per screen; fail CI if budgets regress.
  • Automated scroll and interaction tests on mid-tier devices to catch jank early.

Release Engineering

  • Deterministic builds: lock dependencies; record build provenance (compiler, minifier, targets).
  • Safe rollouts: stagger by percentage and geography; monitor KPIs; auto-rollback on alerts.

Developer Experience

  • Local perf HUD showing frame time, mutation counts, and memory; toggle with a gesture.
  • Templates for common patterns (lists, forms, dashboards) with pre-tuned recycling and throttling.
  • Docs that explicitly call out "do not" patterns (layout in scroll handlers, unbounded caches, synchronous bundle IO).

Code Examples

Enterprise-Grade List with Recycling and Image Budget

// TSX sketch (conceptual)
const cache = new LRUCache({maxBytes: 32 * 1024 * 1024});
function Thumb({src, w, h}) {
  const key = `${src}@${w}x${h}`;
  const [img, setImg] = useState(cache.get(key));
  useEffect(() => {
    let canceled = false;
    if (!img) {
      decodeAsync(src, {w, h}).then(bmp => {
        if (!canceled) { cache.set(key, bmp); setImg(bmp); }
      });
    }
    return () => { canceled = true; };
  }, [src, w, h]);
  return <image source={img || placeholder} width={w} height={h}/>;
}
function Row({item}) {
  return (<horLayout height={88}>
    <Thumb src={item.thumb} w={88} h={88}/>
    <verLayout weight={1}>
      <label text={item.title} maxLines={1}/>
      <label text={item.subtitle} maxLines={2}/>
    </verLayout>
  </horLayout>);
}
export function ListScreen({data}) {
  return <recyclerView itemHeight={88} data={data} renderItem={Row}/>;
}

Defensive Native Module (Android)

// Kotlin pseudo-implementation
class DeviceModule : DoricModule {
  @Export fun getBattery(cb: (Result<Int>) -> Unit) {
    GlobalScope.launch(Dispatchers.Default) {
      try {
        val pct = readBatteryPct()
        withContext(Dispatchers.Main) { cb(Result.success(pct)) }
      } catch (t: Throwable) {
        withContext(Dispatchers.Main) { cb(Result.failure(t)) }
      }
    }
  }
}
// Guarantees: off-main work, main-thread callback, error propagation

Lifecycle-Safe Timers

// JS
function useInterval(fn, ms, deps) {
  useEffect(() => {
    const id = setInterval(fn, ms);
    return () => clearInterval(id);
  }, deps);
}
function TickingLabel() {
  const [n, setN] = useState(0);
  useInterval(() => setN(n => n + 1), 1000, []);
  return <label text={`Ticks: ${n}`}/>;
}

Conclusion

Doric can power polished, native-feeling experiences across platforms, but at enterprise scale it demands the same rigor you apply to any performance-critical UI stack. Treat the JS engine, bridge, renderer, and assets as tunable subsystems; measure them, set budgets, and enforce guardrails. Most severe incidents—jank under load, memory creep, flaky updates—trace back to a handful of patterns: synchronous IO, chatty bridges, layout thrash, unbounded caches, and lifecycle leaks. Apply the diagnostics and fix kits in this guide, formalize your compatibility matrix and release discipline, and you'll keep Doric screens fast, stable, and trustworthy as your product surface grows.

FAQs

1. How can I prove that jank is caused by the JS side rather than native rendering?

Measure frame time alongside bridge batch counts and GC pauses. If jank correlates with high mutation counts or long JS GC, reduce bridge chatter and split JS work. If frame time spikes without JS pressure, profile native layout and image decode hot paths.

2. What's the safest way to roll out remote Doric bundles?

Serve bundles via signed manifests with content-hash URLs, stage rollouts, and retain an instantly switchable previous version. Always upload sourcemaps so production crashes map to meaningful code locations.

3. How do I handle images at scale without blowing memory?

Decode to target size on background threads, prefer WebP/AVIF where supported, and cap caches with LRU policies. Evict aggressively on memory warnings and when the app goes to background.

4. Why do my gestures behave differently on Android and iOS?

Defaults for hit testing, z-order, and gesture recognizer precedence differ. Set explicit z-index/elevation, normalize hit-test areas after transforms, and coordinate back/edge gestures with the host platform.

5. How do I prevent lifecycle leaks when screens are rapidly mounted/unmounted?

Centralize teardown in onUnmount hooks, cancel outstanding promises and timers, and guard native callbacks with isMounted checks. Debounce navigation so mounts do not happen multiple times per transition.