Back to articles

Interview Prep

Android Memory & Performance Interview Questions — 17 Investigation-Driven Prompts for Senior Roles

The performance round at a senior Android interview is rarely “define jank” or “what is an ANR.” It’s some version of: “Your app drops to 30fps when scrolling the home screen. The PM has been complaining for two weeks. Walk me through your investigation.” The candidate’s response in the next two minutes tells the interviewer almost everything they need to know.

This post is 17 of those investigation-prompts. Distinct from my other interview-prep posts — the Top 50 was breadth, Compose 25 was depth on one topic, MVVM/Architecture was scenario-driven decisions. This one is debugging-driven: each question describes a real performance problem, and the answer is a methodical investigation flow with the tools, hypotheses, and signals a senior engineer would use. Memorizing the answers won’t help; absorbing the investigation pattern will.

Examples come from a video streaming app because video is the canonical performance-pressure domain — decode, render, network buffering, large-image thumbnail grids, battery drain during playback. If you can debug performance in a video app, you can debug it anywhere.


Section 1: Memory (1–4)

1. Your app’s memory grows steadily during a 30-minute playback session, eventually OOM-ing. How do you investigate?

Step 1: confirm it’s a leak vs. legitimate growth. Open Memory Profiler, force a GC, watch the post-GC trough over time. If the trough rises (e.g., 80MB → 90MB → 110MB after each GC), it’s a leak. If it stays flat and only the peaks rise, it’s churn — different problem.

Step 2: take heap dumps at intervals (start of session, 10min in, 30min in). Diff them by class instance count. Whatever class’s instances grow over time is suspect — in a video app, often something like VideoPlayerFragment, ExoPlayer, or MediaCodec wrapper objects.

Step 3: check LeakCanary’s output if you have it integrated. If LeakCanary points to a destroyed Activity that’s still in memory, follow the reference chain to the GC root. The chain shows you what’s holding it alive — usually a long-lived listener registered on a singleton that never got unregistered.

Step 4: if no leak found but memory still grows, suspect native allocations. MediaCodec, SurfaceTexture, and decoder buffers live in native memory and don’t show in the Java heap. Use adb shell dumpsys meminfo — the Native Heap line tells you what the Java profiler can’t.

What they’re testing: whether you have a methodical investigation flow vs. shotgun-debugging. Senior engineers don’t guess; they measure, hypothesize, verify.

2. The interviewer follows up: LeakCanary points at a leaked Activity, with the chain going through a static field on a singleton. What do you do?

The fix is structural, not a one-liner: stop holding Activity context in singletons, period.

For the immediate hotfix: find the assignment. MyManager.context = this in Activity.onCreate. Either change it to applicationContext (if the singleton genuinely needs context) or remove the field entirely and inject context where it’s actually needed.

The structural fix that prevents recurrence: use Hilt’s @ApplicationContext qualifier so the dependency graph enforces “singleton can only get app context.” Activity context can’t even reach a @Singleton via the type system. Compile-time prevention beats runtime detection.

Senior add-on: in this kind of investigation, after fixing the immediate leak, run a 30-minute soak test before declaring victory. LeakCanary catches obvious cases but not slow drift — the soak test is what tells you the real underlying memory pattern is healthy.

3. The video player works fine on a Pixel 8 but OOM-crashes within 5 minutes on a Galaxy A12 (4GB RAM, mid-range from 2021). What’s likely happening?

Three hypotheses, in order of likelihood:

(a) Memory pressure is fundamentally different on lower-end devices. 4GB total RAM with the OS, system services, and other apps eating ~2GB leaves your app with maybe 200–400MB before the system starts pressuring you. Code that “works” on a flagship at 800MB usage simply can’t fit. Test this with ActivityManager.getMemoryClass() — the per-app heap limit on the A12 might be 192MB or 256MB.

(b) Bitmap and decoder memory. Image decoder allocations on lower-end devices have less native heap available. Loading 4K thumbnails (which work on Pixel) blows up here. Check what variants your image loader is requesting; downscale aggressively for low-RAM devices.

(c) Background services and processes. The flagship has more memory available; the A12 might be killing your background prefetch service or having it killed. Check onTrimMemory() callbacks — if they’re firing with TRIM_MEMORY_RUNNING_LOW, the system is asking you to release memory and you’re probably not.

The right response in interviews: don’t guess which one. Run the app on the A12 with the profiler attached. The numbers will tell you which hypothesis is right.

4. The interviewer asks: how would you reduce per-frame allocation in a feed that’s decoding video thumbnails?

This is a textbook recyclability question. The answers, in order of impact:

(1) Use an image loader with a memory cache and Bitmap pool (Coil, Glide). They reuse Bitmap objects across positions in a list rather than allocating fresh ones each time. The savings are real — tens of MB and dozens of GCs avoided per scroll session.

(2) For very large thumbnails, request smaller variants. If your view is 200dp tall and you’re decoding a 1080p source, you’re wasting 90% of the decode work. Coil/Glide do this automatically based on the target view size.

(3) For videos, use thumbnail extraction at the right time. MediaMetadataRetriever is heavyweight; if you’re calling it during scroll, you’re jank-bound. Extract thumbnails ahead of time (background WorkManager job) and cache them as static images.

(4) For decoded RGB data, use RGB_565 instead of ARGB_8888. Halves the memory at the cost of color depth. Often invisible for thumbnails.

The senior addition: measure first, optimize second. The Allocation Tracker in Android Profiler shows you which line of code is allocating the most. Optimize the top three offenders, not whatever you guessed.


Section 2: Frame Rate & Jank (5–8)

5. The home feed scrolls smoothly on Wi-Fi but stutters on cellular. What’s your investigation?

Counterintuitive: this is rarely a network problem in itself. The reason: smooth scrolling and network are usually decoupled.

The actual chain: cellular → slower image downloads → image loader is repeatedly hitting the network during scroll → main-thread callbacks fire when bitmaps decode → decode work happens during scroll instead of being precomputed.

The investigation: open Systrace (Android Studio Profiler → CPU → System Trace). Scroll the feed on cellular. Look for long bars on the main thread during the scroll. If you see BitmapFactory.decodeStream or image-loader callbacks consuming 8+ms frames, you’ve found it.

The fixes:

(a) Prefetch images for the next 5–10 visible items. The loader has time to decode while not scrolling.

(b) Use thumbnail variants. A 50KB thumbnail decodes in <1ms; a 500KB hero image takes 10–15ms.

(c) Move decode off the main thread. Coil and Glide do this by default; if you’ve done custom Bitmap loading, audit it.

The signal: the candidate who jumps to “the network is slow” misses the thread-handoff problem. The candidate who realizes “something downstream of slow network is blocking the main thread” gets the senior tick.

6. Layout Inspector shows a composable recomposing 47 times during a single scroll tick. What do you do?

47 recompositions per scroll tick means an unstable parameter or a scope problem. The investigation:

(1) Add label to your animations and state, then use the Compose Compiler Reports (set reportsDestination in build.gradle). The report flags every composable as Skippable / Restartable / Stable. The offending composable will be marked unstable.

(2) Look at the parameters. Common culprits: a List<T> from the standard library (treated as unstable), a captured lambda that’s recreated on every parent recomposition, a Modifier being constructed inline in a way that breaks chain identity.

(3) Fix it: use ImmutableList from kotlinx-collections-immutable (compiler treats it as stable), or annotate your data class with @Immutable, or hoist the lambda to a stable reference with remember.

The signal in the interview: the candidate who says “I’d add @Stable” is half-right. The candidate who explains why @Stable might or might not work (it’s a contract, the compiler trusts you, but you have to actually be stable underneath) gets the depth signal.

7. The first frame appears 1.8 seconds after tap. The interviewer asks where the time goes.

The breakdown of cold start, in rough proportions for a typical app:

┌────────────────────────────────────┬──────────────┐
│ Phase                              │ Typical %    │
├────────────────────────────────────┼──────────────┤
│ Process fork (Zygote, OS)          │ ~10%         │
│ DEX/OAT loading                    │ ~15%         │
│ Application.onCreate               │ ~30–50%      │
│ Activity.onCreate + first layout   │ ~15–25%     │
│ First frame draw                   │ ~10–20%     │
└────────────────────────────────────┴──────────────┘

Application.onCreate is almost always the biggest fixable chunk. Capture a system trace during cold start; the trace shows you exactly where the time goes per phase.

The investigation steps the senior gives:

(1) Run adb shell am start -W -n com.example.video/.MainActivity for a baseline. TotalTime is the cold start in ms.

(2) Capture a system trace. Look at the main thread during the “Application onCreate” section — long bars are the work to scrutinize.

(3) Identify what’s in Application.onCreate that doesn’t need to be there at first frame. Crashlytics yes; analytics, ad SDK, remote config, feature flags — defer to after first frame.

(4) Use App Startup library to convert ad-hoc init into structured initializers; defer non-critical ones to a post-first-frame Handler.post.

(5) After clean-up, ship baseline profiles for another 15–30% improvement essentially for free.

The senior signal: knowing the order of operations matters. Don’t ship baseline profiles before fixing Application.onCreate — you’re AOT-compiling slow code paths.

8. The video player is dropping frames during playback — not scrolling, just playback. Investigation?

Different beast from scroll jank. Video playback dropped frames usually mean the decode pipeline can’t keep up.

The investigation:

(1) Check whether you’re using hardware decoding or software. MediaCodec with type-secure or hardware codecs is fast; pure software decode for 1080p is borderline on mid-range devices.

(2) Check the codec. H.264 is broadly supported in hardware. AV1 is the new hotness but hardware support varies; falling back to software AV1 decode kills mid-range devices. Test on the actual device fleet.

(3) Check what else is on the GPU. If the player is in a Compose UI with heavy graphics work alongside (animations, blur effects, gradient backgrounds), the GPU is contested. SurfaceView-based playback isolates the video onto its own surface; TextureView shares the UI surface and competes for GPU time.

(4) Profile with GPU rendering profiling enabled. The bars show you per-frame: which is taking too long — CPU work, GPU command issue, GPU execution, or display compositing.

(5) Adaptive bitrate. If the network can’t deliver 1080p fast enough to stay ahead of playback, the buffer underruns. Lower the streaming quality.

The candidate who jumps to “maybe lower the bitrate” misses 80% of the problem space. The full investigation considers decode pipeline, GPU contention, and only then network.


Section 3: Battery (9–11)

9. Users complain the app drains battery during playback. What metrics matter and how do you measure?

Battery drain has many possible causes; measurement is essential.

The tools:

(a) Battery Historian on a bug report (adb bugreport). Shows you wakelocks, jobs, alarms, partial wakeup time, sensor usage during the test session.

(b) Battery Profiler in Android Studio. Lower fidelity but real-time; shows CPU, network, location, GPS usage over time.

(c) System trace with power data. Modern Pixels expose per-rail power data; you can see which subsystem (CPU, GPU, modem, screen, audio) is drawing what wattage.

The investigation:

(1) Run a baseline: 30 minutes of playback on a fully-charged device, measure battery percentage drop. ~10% drop in 30 minutes for HD video is expected; 25% drop is bad.

(2) Compare to peer apps (Netflix, YouTube). If your app drains 2× faster, you have a real problem; if it’s within 20%, you’re probably hitting fundamental physics.

(3) For diagnosis, isolate variables: same content, screen brightness fixed, Wi-Fi vs. cellular comparison. The delta tells you network is the dominant factor.

The signal: the candidate who proposes “I’ll just lower the brightness” misses that battery is multivariate. Measurement-first thinking is the senior signal.

10. The investigation reveals that the app holds a partial wakelock for the entire playback session. The interviewer asks if that’s correct.

Mostly yes, with caveats.

For active video playback, you need the screen on (handled by the player UI, no manual wakelock needed). If audio plays in background after the user backgrounds the app, you need a foreground service of type FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK — that’s the right wakelock-equivalent for media.

What’s NOT correct: holding a wakelock during paused playback, holding it after the user navigates away, or using PARTIAL_WAKE_LOCK manually when the foreground service mechanism would do.

The senior addition: Doze and App Standby will deprioritize your app aggressively if it’s not in active foreground or media-foreground. If you’re trying to fight Doze with manual wakelocks, you’re fighting the platform — the user’s battery loses, and so do you (Play Store can flag battery-hostile apps).

11. The app prefetches the next 5 episodes for offline viewing. How do you balance prefetch aggressiveness with battery and data?

The right answer here is WorkManager with constraints, not background threads.

val request = OneTimeWorkRequestBuilder<PrefetchWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.UNMETERED)  // Wi-Fi only
            .setRequiresBatteryNotLow(true)                  // Skip if <15% battery
            .setRequiresCharging(false)                      // Don’t require charging
            .setRequiresDeviceIdle(false)                    // Allow during use
            .build()
    )
    .build()

The constraints offload the policy to WorkManager: the prefetch only runs when conditions are met. The user doesn’t need to manage anything, and the system handles deferring work intelligently.

The senior addition: what about cellular prefetch — some users want to download episodes for a flight when only LTE is available? That’s a user-explicit action, not a background prefetch — promote it to a Foreground Service with progress UI. Prefetching automatically over cellular without consent is a battery and data cost users hate.


Section 4: Network & Disk (12–14)

12. The home screen takes 800ms to load even with full network. The interviewer asks where the time goes.

The investigation pattern: instrument with timing logs at each layer. OkHttpClient interceptor that logs request start, response start, response end. Repository layer logs DB query time. ViewModel logs state-emit time.

Common breakdowns:

(1) Network request setup (DNS, TLS handshake) — 50–200ms on cold connection. Mitigate with connection pooling; OkHttp does this by default.

(2) Server processing time — 50–500ms depending on backend. Inspect Server-Timing headers if present.

(3) Body download — depends on response size. A 200KB JSON over 4G is ~150ms.

(4) JSON parsing — usually <20ms for reasonable payloads. Moshi codegen is faster than Gson reflection.

(5) DB write + Flow emit — 20–50ms.

(6) UI render of the first item — 20–100ms depending on complexity.

If the total is 800ms but no individual step is >200ms, the bottleneck is probably sequential: things happening one after another that could happen in parallel. The fix: kick off the network request and read from the local cache simultaneously. Show cached data first, replace with fresh when network arrives. Perceived latency drops to ~50ms.

13. The video starts buffering after 30 seconds of playback even with strong Wi-Fi. Investigation?

Buffering with strong Wi-Fi is almost always either a backend or buffer-management issue, not local network.

The investigation:

(1) Check the player’s buffer ahead. ExoPlayer’s default DefaultLoadControl targets a 10–15 second buffer. If you’ve overridden this with smaller values, you’re vulnerable to network jitter.

(2) Check the CDN edge. Wi-Fi to your router is fast; your router to the CDN edge might not be. Run a separate bandwidth test (e.g., curl a large file from the same CDN host) to confirm true throughput.

(3) Check whether the bitrate ladder is appropriate. Adaptive bitrate switching takes time; if the player started on a high bitrate and the connection can’t sustain it, you buffer until ABR kicks in. Force a lower starting bitrate for first 5 seconds, let it ramp up if conditions allow.

(4) Check segment size. Hours-long videos with 10-second segments are fine; 60-second segments mean longer buffer-fill time on bitrate switches.

The senior insight: streaming buffering is rarely about “the network is bad.” It’s about the buffer being smaller than the variability of the network. Wider buffers and adaptive bitrate solve most cases.

14. Room queries on the main thread are blocking your scroll. The interviewer asks about strategies.

Step 1, immediate: never block the main thread on Room. Use suspending DAO functions or Flow-returning ones. If you find db.queryBlocking() in a codebase, that’s a bug.

Step 2, structural: even with suspend functions, Room queries can take 50–200ms on cold cache. Strategies:

(a) Smaller queries: instead of SELECT * FROM episodes WHERE userId = ? returning 1000 rows, paginate. Paging 3 with Room.

(b) Indexes on filter columns: if you’re querying by userId or seriesId, those need indexes in the entity definition. Without them, you’re doing full-table scans.

(c) Schema design: if you’re joining 5 tables for the home screen query, denormalize. A “HomeFeedItem” table updated by a background process can be queried with one statement instead of a join.

(d) Move heavy queries off the critical path: when scroll is touching every row of a large table, the right answer is often a separate cached result table that’s precomputed.

The senior insight: “put it on a background thread” is the junior answer. The senior answer is reducing the work itself, not just relocating it.


Section 5: Build & APK (15–17)

15. Your release APK is 65MB. The PM wants it under 30MB. Where do you start?

The investigation tool: APK Analyzer in Android Studio. It breaks the APK down by category — DEX classes, native libs, resources, assets, manifest.

The typical breakdown for a video app at 65MB:

(1) Native libs (libavcodec, ffmpeg, custom video codecs): often 15–30MB. Mitigation: ship only the architectures you support (splits.abi.enable = true in Gradle), or use App Bundle so Play Store does it for you. Cuts native lib size 60–75% for users.

(2) Image assets in res/drawable: 5–15MB. Mitigation: WebP for raster images, vector drawables for icons, drop unused assets. shrinkResources in Gradle catches unused.

(3) Code (DEX): 5–15MB. Mitigation: R8 with isMinifyEnabled. Audit dependencies — one transitive Guava can drag in 2MB.

(4) Bundled fonts/animations: 2–10MB. Often candidates for downloadable resources or selective bundling.

(5) Hidden bloat: localization strings for languages you don’t target, full-resolution drawables in drawable-xxxhdpi when most users have xxhdpi, debug symbols not stripped.

The senior signal: don’t guess. Run APK Analyzer, sort by size, attack the biggest item first. A 30MB target from 65MB is usually achievable with App Bundle + R8 + WebP, no exotic tricks.

16. Build times are killing the team. Clean release builds take 8 minutes. What’s the investigation?

Build performance is its own discipline. The investigation:

(1) Run with --scan to get a Gradle Build Scan. It tells you which tasks took the longest. Usually :app:compileReleaseKotlin + :app:bundleRelease dominate.

(2) Check for bottlenecks in the dependency graph. If everything depends on a single “:common” module, every change rebuilds the world. Modularization with proper boundaries enables Gradle’s build cache to skip unchanged modules.

(3) Enable Gradle build cache (org.gradle.caching=true) and configuration cache. The first cuts compilation reuse, the second skips Gradle’s configuration phase on warm builds.

(4) For CI: use a remote build cache shared across PR builds. The first build of any commit is slow; subsequent ones reuse cached outputs.

(5) Consider whether R8 should run on every PR. Disable for PR builds (debug variant), enable only for release/main builds. R8 is the slowest part of release.

(6) Profile incremental builds, not just clean. Most developer time is incremental; if those are slow, the whole team feels it.

The senior signal: knowing that “clean” vs. “incremental” build performance is a different problem. Most clean builds are CI; most incremental builds are developer-machine. Optimize each separately.

17. The interviewer asks: how do you prevent performance regressions from shipping?

This is the meta-question and the right answer is “automation, not vigilance.” Three tiers:

(1) Macrobenchmark in CI for cold start, scrolling jank percentage, key-flow timings. The benchmark fails the build if cold start regresses by >10%. This is the strongest defense; bugs caught in CI never ship.

(2) Crashlytics + custom performance metrics in production. Track P50/P95/P99 of key flows: app launch, first scroll, search response. Alert on regression beyond a threshold. Even after the build passes CI, real device variability can surface issues.

(3) Lint rules and architectural enforcement. Custom lint that catches main-thread Room calls, Activity context in singletons, hard-coded heavy SDK init in Application.onCreate. Cheap to add, catches whole categories of regression at PR time.

The senior signal: the candidate who says “we have a perf-aware culture” is a junior answer. The candidate who says “we automate the boring parts so the culture doesn’t have to remember” is the senior answer. Performance is a property of systems, not of people’s attention.


The Investigation Pattern That Wins

Across all 17 questions, the same skeleton appears in good answers:

(1) Confirm the symptom is real. Don’t debug a problem you haven’t reproduced. “Have you measured this?” is the senior interviewer’s favorite first question, and asking it of yourself before debugging is the senior’s favorite first move.

(2) Form hypotheses, ranked by likelihood. Most performance bugs are well-known categories — main-thread blocking, recomposition storms, leaked references, undersized variants, oversized payloads. List the top three; pick the most likely.

(3) Test the top hypothesis with a tool, not a guess. Profiler, Systrace, LeetCanary, APK Analyzer. The tool tells you whether the hypothesis is right.

(4) Fix the root cause, not the symptom. “Add a try-catch” is a junior fix. “Make this call non-blocking and add a metric so we know if it regresses” is a senior fix.

(5) Verify the fix actually fixed it. Re-measure. Soak test if memory; multi-device if rendering; load test if network. Many candidates declare victory after the symptom disappears once.

The interview rarely lets you complete this loop — you’ll be cut off after step 3 or 4. But articulating that you would do all five is what separates “has solved problems” from “has solved problems methodically.”


Closing

The performance round is the closest thing to a real-job simulation an interview can be. Memory leaks, dropped frames, slow startups, battery complaints — these are the bugs you’ll fix in your first quarter at the new role. The interviewer is asking, in effect: when this lands on your desk on a Tuesday morning, what do you do?

The fundamentals: measure first, hypothesize before guessing, use the right tool for the question, and verify the fix. Memorizing “the 17 questions” helps you recognize patterns. Internalizing the investigation skeleton helps you handle the variant the interviewer actually throws at you.

That’s the Interview Prep — Android quartet wrapped: breadth (Top 50), Compose-depth (25), architecture scenarios (MVVM/Architecture), and now performance investigation (this post). Four different angles on the senior loop. Each one alone won’t prepare you; together, they cover the surface area of what most senior Android interviews actually test.

Happy coding!

6 views · 0 comments

Comments (0)

No comments yet. Be the first to share your thoughts.