Skip to content
Medium

Medium

Read writing from C:\Dave\Storey on Medium. Software engineer & lover of all things code. Too much to learn and so little time. Currently working at Trainline London.

truestorydavestorey.medium.com
MediumMedium
TALENT ACQUISITION AND RECRUITING

Beyond the Code: How to Pass Modern Software Engineering Interviews

Beyond the Code: How to Pass Modern Software Engineering Interviews — title image: Note image generate by AI Software engineering is evolving as a craft; this is an inevitable truth none of us can deny. If you roll the clock back to the early 2000s when I first started interviewing for jobs, the role wasn’t even called software engineering — it was simply called “Developer” because writing code was all we cared about. But now, the word Engineer carries far greater expectation. The rise of AI and cloud computing solutions has already had a profound impact on — and continues to share a symbiotic relationship with — our craft. As these changes continue to accelerate, hiring processes must evolve alongside them. Failure to do so will result in both ourselves as engineers falling behind the curve, and companies failing to hire people who can keep pace with the demands of the industry. As someone who has interviewed, pr helped design or refine interview loops, at many of the major tech companies currently hiring in London (such as Microsoft, Meta, Google, Stripe, Trainline, Monday.com, and Amazon), I have gained valuable first-hand experience through trial and error that can benefit potential candidates. In this blog post, I will cover the high-level aspects of software engineering interviews that candidates need to know, outline the skills expected at different seniority levels, and share a few “behind the scenes” insights from my time on the other side of the desk. ℹ️ Disclaimer: This article is the result of my own writing and creative process. It has not been generated using AI*. The only AI used was for image generation, and any AI-generated images are clearly credited as such.* ### Before we start Before I start I want to give a quick shoutout to the following 3 resources that I always recommend to folks going through this process and indeed have used myself. These are invaluable sources of knowledge and can really help people upskill quickly for interviews: - NeetCode— Built and maintained by Navdeep Singh; this site provides a great number of tutorials, courses and resources for anyone interested in applying to the common FAANG companies. It has a really great set of tools for helping you with LeetCode style problems and really helps break down complex Computer Science concepts and algorithms for folks who are more visceral learners - Hello Interview — Created by former Meta Staff engineers; this site is really great for its AI powered systems design tools and I also really love how it focuses on Community powered discussion through things like Discord and “Ask the Community” learning. - GeeksforGeeks — A great learning portal full of helpful examples and theorem for potential interviews. The vast majority of its content is completely free. - CRACKING the CODING INTERVIEW — The quintesential book that all people doing technical interviews problem need on their eReader or book shelf. This book contains walkthroughs to lots of the common FAANG interview questions, and while it doesn’t cover as much of the modern topics like system design and database design etc. it still has lots of great advice to give. ℹ️ I am not endorsed by any of these sites/resources. I give this as an unbiased opinion as they are resources I have used myself in the past. ### Fundamentals Okay, so first things first: let’s cover some of the core fundamentals I think people need to know before applying for Software Engineer roles, regardless of seniority. These points are based on my own first-hand experience, so some of them may be slightly controversial, but I promise they come from a place of genuine care. #### 1. Communications is key 🗣️ It goes without saying that you can write the best code in the world, but if you can’t communicate your thought process, you will rarely get far in the process. When you unpick standard interview loops, you realise that writing code is actually only a small fraction of the overall assessment; being an effective communicator, on the other hand, is required at every single step. Remember that one of the questions interviewers are constantly asking themselves in the back of their minds is: “Can I communicate effectively with this person?” Failing to explain a complex algorithm or struggling to articulate your contributions to a past project will almost certainly lead to a poor outcome. I would strongly advocate that candidates dedicate just as much effort to honing their communication and interpersonal skills as they do to mastering code and algorithms. #### 2. Failure to prepare is to prepare to fail 🗺️ If you are currently in a software engineering role, coming out of university, or returning from a hiatus, there is a high chance you will be rusty at interviewing. My top piece of advice is to always prepare before jumping into an interview loop. Hubris will always come back to bite you, so I strongly advise taking time to revise, use online resources, practise coding challenges, and perhaps even run through a few mock interviews before going after a role you are truly passionate about. This happened to me recently when a recruiter approached me for a role that felt like a perfect fit. At the time, I was working at a company and coding daily, so I thought, “I’ll nail this!” I didn’t revise or practise, and guess what: I failed it. As soon as I walked away, I knew exactly where I went wrong and how, but by then it was too late. Don’t let this happen to you. Practise, practise, and practise some more, regardless of how much code you write day-to-day or how confident you feel. #### 3. The role is called “Engineer” for a reason 👷 Spoiler: Code is not the only aspect you will be tested on. Software engineering has evolved significantly, and even junior engineer roles now expect candidates to have exposure to multiple facets of the discipline, such as databases, data ingestion pipelines, system design, architecture, and project management. This isn’t to say companies expect you to be an expert in all of these fields; in fact, depending on the role and the business, certain skills will carry far more weight than others. However, broadening your knowledge across these areas rather than solely focusing on LeetCode tests is not just recommended… it’s essential! #### 4. Interviews are a two way street 🛣️ Remember that interviews are as much for you to find out about the company and the people interviewing you as they are for the company to assess you. Throughout the process, you should make mental notes on how the interviewers behave: are they engaged and approachable, or standoffish and rude? Ask yourself whether their interview practices are modern, interesting, and engaging. There have been roles I’ve interviewed for where I received such immediate red flags — simply from interviewers being rude or disinterested — that I removed myself from the process to save both my time and theirs, having decided I had no interest in working with those individuals. This is not about being spiteful, and I wouldn’t advise withdrawing after a single round that gives you cause for concern; however, as you progress through a loop, keep building a mental model of what it would actually be like to work there every day. #### 5. Nerves are completely natural 🌻 I’ve conducted a lot of interviews where candidates come in to do coding challenges or whiteboarding problems and completely freeze due to nerves. It’s natural; it’s a completely human reaction to feel nervous or anxious during an interview. If an interviewer is good at what they do, they should not only try to foster a calm and inclusive atmosphere, but also adapt their style to help the candidate feel at ease and perform at their best. That being said, if you know you are prone to nerves, it is worth mentioning this to your interviewer before you start. Interviewers aren’t psychic, and while they won’t simply feel sorry for you and give you a pass, they will be able to make adjustments to support you. Likewise, nerves can kick in before you even reach the interview room. Try not to let them get the better of you. I personally find that following an online guided meditation or listening to calming music helps me focus. Finally, I would suggest skipping caffeine beforehand — it might only make you feel more anxious or jittery. #### 6. Cheaters never prosper ⚠️ As someone who has led too many technical interviews in my career to count, I can promise you that it is obvious to interviewers when people are cheating. Tools such as CTRLpotato make bold claims about helping candidates ace tech interviews easily, but in reality, you aren’t fooling anyone. Plucking a random, perfect algorithm out of thin air when mere moments ago you seemed completely stuck, or writing unexplained code that solves the problem without being able to explain what it does or why, are immediate red flags. I have personally stopped interviews early in the past when I have had strong reasons to believe cheating was taking place. ### The Standard Software Engineer Loop Okay, so now that we have covered some of the fundamentals, let’s look at the structure of common Software Engineer interview loops I’ve seen and experienced at the companies I’ve worked at and interviewed for. There are usually five main components that companies will test you on: 1. Interpersonal — Assessing your emotional intelligence, communication, and past experiences. 2. Technical/Code — Assessing your ability to solve problems using code or pseudocode. 3. System Design — Assessing your ability to design a complex, multi-component system, usually via a whiteboard diagram. 4. Project Management/Review — Assessing how you delegate and prioritise work, as well as how you evaluate a project’s success or failure in a hypothetical scenario or past project. 5. Cultural/Team Fit — Determining whether you would be a good fit for the company’s and team’s culture. These steps may occur in any order, but companies usually try to parallelise them as much as possible, especially if the interviews take place on-site. For example, the Meta interview loop I completed had five 1-hour interviews in a single day, whereas my interview loop for Monzo had a 1-hour system design interview followed immediately by a 1-hour coding challenge. Additionally, a pattern I’ve seen becoming increasingly popular is that individual interviews are rarely a strict pass/fail scenario. Companies nowadays tend to evaluate candidates across the entire loop, making a final assessment based on feedback from all rounds combined. This means that a candidate who is strong in coding and interpersonal skills, but weaker in system design, may still reach the offer stage. I would therefore strongly recommend balancing your preparation to focus on areas where you feel you have gaps, rather than over-indexing on one area over the others. ℹ️ Note: This does not mean that there will be 5 rounds, sometimes there are, but sometimes companies will combine these assessments into one round. For example interpersonal and cultural fit may well be combined into one interview but split into two parts. Likewise you may get a technical test which is one half coding, then the other half designing a system. Good companies will often have their process well documented online via their careers site, or will provide it to you via email once you enter the process. It is always good to familiarise yourself with the different rounds/loops you will be facing so you can prepare ahead of time and know how many interviews to expect. You may also be presented with the names of the people interviewing you so you can check LinkedIn and see their level and experience. So now lets break each of these down and I can give you some more specific tips. ### 🧑‍💻 The Interpersonal Round #### 🤔 What is it? This is the classic “Tell me about a time when…” or “Give me an example of how…” style question. Many engineers view this as a “soft skills” assessment because there is usually little to no connection to coding or system design. These questions are designed to evaluate how you work with others, handle difficult situations, or navigate failure scenarios. They commonly break down into the following main categories: - Conflict & Difficult People — Example: How would you handle a conflict between yourself and another member of staff? - Communication & Listening — Example: How do you respond to criticism or feedback from team members? - Teamwork & Collaboration — Example: What would you do if you and a teammate disagreed on a solution? - Customer & Client Interaction — Example: Can you talk about a time when you had to interact with a difficult customer or client? #### ℹ️ How is this round usually run? Usually, this round is conducted remotely and flows in a conversational style and is the shorter of all the interview rounds lasting somewhere around the 30–45 minute mark. For many interviews I’ve done, this has been the first round of the process and almost a “Go/No-Go” gauge to assess whether or not a candidate stands a good chance of making progress in the subsequent steps of their interview process. The interviewer will ask you deliberately open ended questions similar to those above and will then assess your answers. The interviewer may decide, following your answer, to probe a bit deeper into a particular part of your answer or ask follow-on questions to gain more insight into your personal experiences. The interviewer may decide that there is nothing more to assess based on your answer, and may just choose to move onto another question without asking follow on points. #### 💡 Tips for this round - Prepare concrete examples: Gather scenarios from previous work or projects. If you are fresh out of education then instead you should think of examples from your coursework or group activities. - Vary your stories: Try not to draw all your answers from a single project. Using multiple examples will add variety and depth to your responses. - Use the STAR method: Structure your answers around Situation, Task, Action, and Result. This keeps your answers clear and ensures you cover everything the interviewer is looking for. - Keep it concise: Avoid waffling. Talking for 20 minutes in a single response will not work in your favour. Focus on getting the main points across succinctly, giving the interviewer time to ask follow-up questions. - Highlight growth: Showcase your strengths, but do not be afraid to acknowledge skill gaps. Demonstrating self-awareness and how you learn shows a strong growth mindset. - Frame your role thoughtfully: Reframing your involvement slightly (e.g. stating you “led” or “designed” a feature when working in a small pair) is fine, provided you can explain exactly what you contributed. However, never outright lie about your achievements, as interviewers will spot inconsistencies and your credibility will be lost. #### 🔍 Insider knowledge Whenever I’ve conducted this type of interview, I’m often more interested in what the candidate doesn’t share. Interviewers can usually get a sense of whether a candidate is being vague or cagey in their responses, and that is precisely where we will probe deeper. As interviewers, we can also usually tell when someone is exaggerating or misleading us, and we will certainly notice when a candidate contradicts themselves in subsequent answers. ### 🔧 The Technical Round #### 🤔 What is it? This round is designed to test your ability to code and solve technical challenges. You will usually be presented with a single problem statement and asked to develop a solution. The interviewer is trying to evaluate your problem-solving approach while simultaneously assessing your communication skills, thought process, and fluency in your chosen programming language. Often, companies will present you with either a common algorithmic-style question (similar to those found on LeetCode or HackerRank) or a more practical, system-style scenario where you are asked to build an API or service based on a set of requirements. Even though the software engineering landscape has changed significantly over the last 10+ years, many of these interview questions are still designed to test your knowledge of Data Structures and Algorithms (DSA). These topics form the backbone of efficient programming and problem-solving: Data Structures #### ℹ️ How is this round usually run? This round is usually conducted in person or remotely. A problem statement may or may not be shared with you in advance; if it is provided ahead of time, you might be allowed to solve it in your own time as a “take-home test”, but often it is designed to be completed within a fixed window of 60 to 90 minutes. The problem may also feature multiple or follow-on parts that are revealed as the interview progresses. The typical format for this interview involves using either the candidate’s IDE of choice (sharing their screen if remote) or an online coding environment such as CoderPad, LeetCode, or HackerRank. In my experience, the larger the company (Meta, Microsoft, AWS), the more likely they are to pay for one of these dedicated platforms and focus on algorithmic-style questions. Smaller companies usually opt for screen sharing and place greater emphasis on general problem-solving or service-building challenges. #### 💡 Tips for this round - Understand the scope: This round may target specific programming languages used by the company, though the best companies provide language-agnostic challenges. Confirm what they expect in advance. - Master core data structures: Brush up on common structures in your language of choice, including HashTables, Maps/Dictionaries, Arrays (single and multi-dimensional), Stacks, Queues, Trees, and Tries. - Review essential algorithms: Refresh your knowledge of Breadth-First Search (BFS), Depth-First Search (DFS), Dijkstra’s, various sorting algorithms, linked list manipulation, and identifying duplicates in collections. - Read instructions thoroughly: When presented with the problem, read it carefully. For take-home challenges, read everything before starting to catch solving clues, company preference hints, or specific rules (such as “Don’t bring any code to the interview”). - Prepare for API and service design: If solving a service or API problem, revise good design practices like SOLID and TDD. For .NET specifically, understand dependency injection lifetimes, how async-await works and how to pass route and query string parameters to controller actions. - Revisit fundamental language concepts: Review reference vs value types, stack vs heap memory, and code execution (compiled vs interpreted). For Senior+ roles, prepare for questions on advanced concepts like idempotency and concurrency. - Know your Big O notation: Be ready to calculate and explain the time and space complexity for any solution you write. - Practise on coding platforms: Work through LeetCode or HackerRank questions suited to your target seniority. For a rough guide I would advise for a Junior role focus on Easy-to-medium challenges, for Senior roles focus on Medium challenges and for Staff+ Medium-to-hard challenges. Note: This builds algorithmic fluency and familiarises you with standard online interview environments. #### 🔍 Insider knowledge When I’m conducting this type of interview, I want to see that the candidate is comfortable writing code and can clearly explain their thought process. For me personally, I don’t care if you practise strict TDD, but I do want to see some consideration for testability; or I at least expect a quick discussion around why it isn’t being done. I wanted the interview to be more akin to a pair programming task than me just staring at the candidates screen in silence and marking their work; because that is how we work as engineers; collaboratively and cooperatively. I want to see good, clear intent in the code even if it doesn’t work, and more importantly, I want to see you make steady progress. If you spend 15 minutes coding up edge-case conditional checks for numerous negative scenarios while failing to tackle the core problem, I am likely to think you are stalling or that you are the type of developer who hyperfixates on YAGNI scenarios and premature optimisations. That is not to say that I expect candidates to complete everything in the time limit, hey that’s great if they can, but not essential. Remember: you have limited time and need to use it effectively, focus on areas of code that show good knowledge of DSA skills. ### ✏️ The Systems Design Round #### 🤔 What is it? System design has gained significantly more focus in interview loops over the last decade. It is difficult to overemphasise just how much weight companies now place on this stage of the process. This shift is largely driven by the evolving nature of the role itself: transitioning from traditional “Developer” positions to broad-scope “Software Engineer” roles, where engineers are expected to be multi-disciplinary. The rapid adoption of cloud computing platforms like AWS, Azure, and GCP has further accelerated this trend. Today, system design is treated almost as an art form, complete with established frameworks designed to help candidates structure their responses. Furthermore, system design serves as a crucial differentiator for hiring teams. Unlike traditional coding questions, high-level architecture discussions require real-time trade-off analysis, operational reasoning, and adaptive communication — the skills where candidate capability genuinely shines through. #### ℹ️ How is this round usually run? This round takes place either in person or remotely using a whiteboard. If remote, you will typically be asked to present your solution using a digital tool provided by the company or one of your own choice, such as Excalidraw, Miro, Draw.io, or built-in virtual whiteboards. In person, this usually involves a physical whiteboard and coloured dry-erase markers. The interviewer will present a high-level problem statement (for example: “design an image-sharing platform like Instagram” or “build a ticket-retailing system like Ticketmaster”) and ask you to design a feasible solution using modern cloud technologies. You are not expected to know the exact inner workings of these platforms beforehand; rather, the objective is to demonstrate how you break down complex requirements and apply logical reasoning. Interviewers will assess how you navigate trade-offs and rationalise your architectural decisions. They will also gauge whether you are up to date with modern best practices. For example, using Kafka for data ingestion pipelines and event-driven architectures based on Change Data Capture shows a higher level of technical maturity than relying on naïve, legacy approaches like dedicated polling or scheduled batch jobs. ⚠️ Important note: Unlike coding rounds, which often have a single optimal algorithm, system design interviews rarely have a single “right” answer. However, this does not mean there are no poor solutions or critical architectural pitfalls to avoid. #### 💡 Tips for this round - Adopt a structured framework: Find an approach that helps you structure your answer clearly and concisely. I personally advocate for the framework taught by hellointerview.com, but there are many options available. Having a repeatable plan before you step into the room is essential. - Clarify constraints early: Spend the first few minutes defining what is in scope versus out of scope, agreeing on these boundaries with your interviewer. Ask directly about scale, target response times, and geographical distribution. You cannot design a system effectively without knowing its expected load and latency requirements. - Focus on a core cloud platform: Stick to a provider you know well (such as AWS, Azure, or GCP). You do not need to memorize every niche service, but you should understand core components: compute, storage, databases, caches, message queues, and serverless options. - Master data storage and retrieval: Understand the operational trade-offs of different database paradigms, including relational, NoSQL, in-memory caches, and object stores. Knowing when an object store or cache is more appropriate than a traditional database is key. - Prepare for scaling questions: Whenever you introduce a database, expect follow-up questions about how it will scale under 100x to 1000x throughput. Concepts like data sharding, hot partition mitigation, and consistent hashing are fundamental to distributed systems design. - Brush up on the CAP Theorem: Almost every system design loop touches on CAP theorem trade-offs. Be prepared to explain which properties you are prioritizing. Remember, these decisions do not have to be uniform across the whole system: you might prioritize Availability for event ingestion while enforcing strict Consistency for payment processing. - Assume cost is secondary unless instructed otherwise: Candidates often get unnecessarily bogged down in estimated cloud bills. Unless the interviewer explicitly states a budget constraint, assume cost is not an issue and focus on architectural simplicity and reliability. - Set your communication style to “verbose”: Clear communication is critical. Talk through your thought process out loud, ask clarifying questions, probe edge cases, and explicitly state why you are selecting one component over another. ℹ️ Information: There are many great resources for helping you prepare for systems design interview rounds. I personally used the hellointerview.com site common interview questions guided breakdowns as I am a very visceral learner and their AI tools to help you practice with a whiteboard really helped me with my preparation. #### 🔍 Insider knowledge Communication is key in this interview. As an interviewer, I can often excuse mistakes or poor design choices, or steer someone back on track, if they provide clear explanations and rationale for their decisions. Listen closely to your interviewer during this round; they will often attempt to nudge you back in the right direction if they notice you are missing a critical facet of the system. Be open to their feedback and adjust as necessary. Additionally, I highly recommend starting with a very high-level design, even if it is sub-optimal, before diving into lower-level details and refinements. It is far better to present a complete, high-level overview that you can iterate on, rather than leaving the interviewer with a half-baked, incomplete solution. ### 📊 The Project Management/Review Round #### 🤔 What is it? This type of interview is currently quite rare, but it is routinely used at most FAANG companies and at start-ups founded by ex-FAANG employees who brought these practices with them. You may also encounter it at organisations without a dedicated Technical Program Manager (TPM) or Product Owner. In these situations, companies want to ensure that you have a project and product mindset, with the ability to reflect on previous work through an objective-driven lens. It is worth noting that this round is typically reserved for Senior+ positions, where candidates have several years of experience to draw upon. #### ℹ️ How is this round usually run? This interview usually takes place remotely, though it can happen in person. It generally follows one of two formats: - The interviewer asks you to present and discuss a project you have previously delivered. - The interviewer gives you a hypothetical project or product scenario and asks how you would run it. You will be expected to walk through the project lifecycle from the initial idea, team formation, and stakeholder buy-in, through to planning, regular progress tracking, delivery, and measuring success. Preparing for this round can be tricky because the scope is so broad and subjective, but focusing on key project management principles will help you succeed. #### 💡 Tips for this round - Reflect on past projects beforehand: Having a real example ready from your experience is invaluable. Consider what went well, what failed, how the team collaborated, how blockers were resolved, and how scope changes were documented. - Keep a diagramming tool ready: Just like in a system design interview, having a digital whiteboard open is immensely helpful. Quickly sketching a workflow or architecture helps ensure you and the interviewer stay aligned. As the saying goes, a picture is worth a thousand words. - Focus on high-level strategy over low-level code: Avoid diving straight into deep technical implementation. If the interviewer wants more technical detail, they will prompt you. You do not need to explain how containers or specific frameworks work unless explicitly asked. - Highlight delivery artefacts, not just technology: Show that you understand the operational side of delivery. Mention tools and practices such as RFCs, design docs, sprint burndown charts, milestones, status reports, and MELT (metrics, events, logs, traces). These are key signals for managing progress and measuring success. - Focus on project execution, not people management: Avoid discussing day-to-day team management unless guided there. The interviewer wants to see how you structure a project, anticipate blockers, and manage trade-offs between quality and technical debt, rather than how you assign tasks to individual engineers. - Embrace the failures: No project goes perfectly to plan. Requirements shift, priorities pivot, and some projects get scrapped entirely. Interviewers appreciate candidates who can discuss setbacks openly and highlight the key learnings gained along the way. Pretending everything ran smoothly will only raise suspicions. #### 🔍 Insider knowledge Unfortunately, I’ve never actually led this type of interview before; however, I can share how I would answer this question. As an engineer leading a project I would reflect back on those projects that I’ve been involved with that have been a success, I would want to discuss how I would take a problem and break it down to simplify or parallelise the work. I would highlight having mechanisms for observability into the project, using tools such as documentation, prioritised backlogs, and agile frameworks like Scrum or Kanban. I would also ensure that anyone can easily track progress through burndown charts or DORA metrics, RFC capture documentation, and comprehensive system design docs. Next, you want to think in terms of DevOps to ensure you have MELT (Metrics, Events, Logs, and Traces) as a first-class architectural decision, allowing you to monitor and assess how the solution is operating. Finally, I would cover carrying out user acceptance testing before launching a feature, while always ensuring robust rollback processes are in place. Finally I would want to reflect on and explain those things that didn’t go according to plan, how they impacted the project and why they weren’t caught earlier in the design process. ### 📏 The Cultural/Team Fit Round #### 🤔 What is it? If you make it to this round, it is usually the final step, so congratulate yourself on getting this far! At this point, you may enter what is sometimes known as a “Team fit”, “Culture fit”, or “As appropriate” round. This is a deliberately vague title because the structure varies by company, but the core objective remains the same. Essentially, this round is designed to have a quick, often informal chat to see whether the hiring manager, CEO, or CTO can picture you working alongside them. It is extremely hard to predict or plan for the questions you will be asked here because there is usually no script. I have often heard this referred to as “The jerk test” because that is its main purpose: to check that you will not disrupt the team dynamic. #### ℹ️ How is this round usually run? For many candidates, this round is the first time they meet anyone in person. Inviting you to see the office or meet the team is a sign of respect, giving you the chance to assess the working environment for yourself. That being said, the interview may still take place remotely if the interviewer is based elsewhere or if the company operates a remote-first policy. The format is typically quite casual. Questions can range from “What do you like to do in your spare time?” and “How do you keep up to date with technological changes?” to “What excites you about working for us?” You might also find that the company hands out free swag to convince you that they are a great place to work. In the past, I have been given branded t-shirts, phone chargers, and even an Alexa device from AWS just for attending an on-site interview. Remember that receiving swag does not guarantee a job offer; companies simply want you to view them favorably if they decide to extend one. #### 💡 Tips for this round - Be yourself: You have made it this far, so try to stay natural and let your true personality show through. - Be engaged: You may reach this stage and decide the company is not the right fit for you. That is completely fine, but if you choose to complete the loop, remember that the interviewer is often senior and deserves your full attention and respect. - Show enthusiasm: A little genuine enthusiasm goes a long way. If you really want the job, make that clear to the hiring manager. It will make you far more memorable if they are deciding between multiple candidates. - Enjoy yourself: This is the one part of the interview process you cannot over-prepare for. There are no technical challenges to solve and no architecture diagrams to draw, so simply enjoy the conversation and highlight why you are the right fit. #### 🔍 Insider knowledge When I was an engineering manager at Microsoft, I knew exactly what kind of engineer or candidate I was looking for to fill an open role on my team. The questions I asked were designed to get to know the candidate better on a personal level. I already knew they had passed all the necessary skill tests, and I trusted the process to ensure that anyone reaching the final stage met our quality gates. I would be considering whether the person sitting across from me was genuinely passionate about what they did, and whether I could see myself working with and managing them. I looked for someone who recognised they weren’t perfect and didn’t have a chip on their shoulder. I wanted candidates who could hit the ground running, demonstrate a growth mindset, and show a real willingness to learn. If I’m being perfectly honest, once a candidate reached this stage in the process, it was effectively their job to lose. I don’t think I ever actually turned someone down at this final step. ### 🤖 The impact of AI and interviewing I would be remiss if I didn’t mention the elephant in the room when it comes to interviewing. AI has fundamentally changed the way we write code, and regardless of whether you are a fan of it or not, you cannot deny the impact it is having. Many companies have explicitly banned AI from their hiring process, deciding that they want to assess candidates purely on their core abilities. Others, such as Meta, have introduced specific AI-coding-assisted interview loop stages. Some companies, including Google, have returned to 100% in-person interviews to prevent candidates from using AI unless it is explicitly permitted. Meanwhile, companies such as Trainline (disclaimer: I actively helped steer and refine the hiring process during my time there) have taken a more pragmatic approach by allowing the tooling, provided candidates can explain why they are using AI and articulate exactly what the generated code does. I find it fascinating that despite day-to-day expectations of the software engineering role changing, standard interview practices largely remain the same. AI is exceptionally good at solving DSA-style questions; if companies are worried that candidates will use it to cheat, why not change the nature of the tasks? Likewise, if you expect engineers to use these tools to complete their daily work, why not test whether they can use them efficiently? Personally, I believe AI is simply another tool in the toolbox. It reminds me of back in the day when candidates would copy and paste chunks of code from Stack Overflow with zero understanding of how it worked, operating on blind faith that it would solve their problem. As an interviewer, my job is to evaluate the person sitting between the chair and the keyboard, determining whether they can do the job regardless of the tools they use to solve the problem I give them. I predict that hiring practices will need to evolve in the very near future. Indeed, the feedback I’m hearing from many friends across the industry is that candidates coming through interview loops are increasingly falling into two camps: they are either technically strong with weak interpersonal skills, or they are clearly losing their engineering edge through prolonged and excessive reliance on AI. However, that is a topic for its own blog post entirely. ### Closing thoughts… Interviewing is an art, both for the person applying for a new role and for the person conducting the interview. In this blog post, I’ve tried to shed light on the different stages of the interview loop as it currently stands across many companies. I’ve shared my own personal insights based on experience from both sides of the table, while keeping specific details of actual interview questions under wraps. I hope this guide helps you prepare for any interviews you are currently facing or planning for in the future. Before I close out, I want to remind everyone that receiving a rejection email or failing an interview is inevitable; it genuinely happens to us all. You will never succeed every single time, but the important thing is to listen to the feedback, take it on board, and learn from it. Avoid making the same mistakes twice, and use that insight to focus your preparation on bridging any remaining gaps. Thank you for taking the time to read my blog 🙏
MICROSOFTMIRO
Always Under Construction | Phan Phan | Substackredditxdg.meGabriel Volpe
MediumMedium
WAREHOUSING AND INVENTORY SYSTEMS

Rubbish Code, Clean Memory: Understanding the .NET Garbage Collector

Rubbish Code, Clean Memory: Understanding the .NET Garbage Collector. NOTE: image generated using AI Automatic memory management is a cornerstone of what makes many modern programming languages and runtimes so appealing to many engineers. The freedom to express intent through code, without necessarily needing to remember to clean up after themselves, however, brings with it potential trade-offs between performance, simplicity, and pitfalls. In the .NET runtime (CLR), this automatic memory management is done by the Garbage Collector (GC) and frees engineers from the laborious task of manually managing memory addresses and allocation for variables. It is, therefore, of critical importance that developers understand its inner workings to ensure their applications are high-throughput and low-latency. In this post, I will attempt to provide a comprehensive deep dive into the .NET Garbage Collector. I will start with some foundational concepts and then move on to some of the mechanisms the collector uses, present some ways to get easy diagnostics, share tips to optimise your GC performance, and then finish with a note about the latest developments in the GC brought about in .NET versions 9 and 10. So come with me as we dive into the Garbage Collector like Oscar the Grouch in Sesame Street! 😊 ℹ️ Disclaimer: This article is the result of my own writing and creative process. It has not been generated using AI*. The only AI used was for image generation, and any AI-generated images are clearly credited as such.* ### Memory Fundamentals: Stack, Heap, and Type Systems Before diving into how the Garbage Collector organizes objects, it helps to step back and look at how .NET manages application memory at the lowest level. Memory in .NET is broadly divided into two primary regions: the Stack and the Managed Heap. #### Stack vs. Heap Allocation - The Stack: A fast, LIFO (Last-In, First-Out) memory structure managed directly by the CPU execution thread. Memory for local variables and execution contexts is allocated and reclaimed instantly as method call frames are pushed and popped. The GC never inspects or collects the stack. - The Managed Heap: A dynamic block of memory reserved by the runtime for arbitrary, long-lived objects. Because allocations here do not follow a strict call-stack lifecycle, the GC must actively track and reclaim unreferenced memory on the heap. #### Reference Types vs. Value Types How a data type interacts with these memory regions depends on whether it is a reference type or a value type: Quick cheatsheet for value vs reference type in .NET. Note: image generated by AI ℹ️ Note: A classic interview pitfall to avoid is oversimplifying where data lives. Always remember: Every local variable gets an allocation on the stack frame. Even for reference types, the stack holds the pointer while the heap contains the actual object. I cannot tell you how many senior engineers I’ve seen get this wrong. As we progress through this post, you will see why having that pointer sit directly in the stack frame is so vital to how the GC tracks references and executes its collection cycles. #### The Hidden Heap Penalty: Boxing and Unboxing While value types generally avoid GC overhead, they can end up on the managed heap through a mechanism called boxing. - Boxing: Occurs implicitly when a value type is cast to object, an interface, or non-generic collection. The CLR wraps the raw value inside a System.Object instance container and allocates it directly onto Generation 0 of the Managed Heap. - Unboxing: The explicit extraction of the underlying value type from the heap instance back into a stack variable. int count = 42; // Allocated on the stack (no GC involvement) object boxed = count; // BOXING: 'count' is wrapped & allocated on the Managed Heap! int unboxed = (int)boxed; // UNBOXING: Value is copied back out to the stackint count = 42; // Allocated on the stack (no GC involvement) ⚠️ Warning: Frequent boxing creates hidden allocation churn on the heap, triggering unexpected Gen 0 collections and making it one of the primary targets for performance optimisation. The concepts of boxing and unboxing used to be very common interview questions back in the early 2000s, but they have almost completely disappeared these days. 🤷 ### Fundamentals: The Managed Heap and Generations When a .NET application starts, the runtime reserves a contiguous region of memory known as the managed heap. Every allocation of a reference type resides here. To optimize memory management, the GC categorises objects into distinct generations based on the generational hypothesis: the empirical observation that most objects die shortly after creation. #### Small Object Heap (SOH) Generations - Generation 0 (Gen 0): The youngest generation containing short-lived objects (e.g., local variables, temporary strings, iterators). Collections occur frequently, take a few milliseconds, and aim to reclaim memory rapidly. - Generation 1 (Gen 1): Serves as a buffer between short-lived and long-lived objects. Objects surviving a Gen 0 collection are promoted here. - Generation 2 (Gen 2): Contains long-lived objects (e.g., static data, application singletons, long-running caches). A Gen 2 collection is a full collection, evaluating the entire heap, which makes it the most expensive operation. ℹ️ Note: All objects that get created by the runtime start off in Gen 0. They then progress through the different generations with each pass of the collector. The longer the object “lives”, the further up the generations it gets and the more costly it gets to clean up. #### Specialised Heap Segments - Large Object Heap (LOH): Objects larger than 85,000 bytes bypass Gen 0 and are allocated directly on the LOH. Historically, the LOH was swept rather than compacted to reduce CPU overhead, leading to potential fragmentation. - Pinned Object Heap (POH): Introduced to isolate pinned objects (e.g., buffers passed to unmanaged code via interop). This prevents heap fragmentation on the small object heaps caused by fixed memory addresses. - Frozen Object Heap (FOH): Dedicated exclusively to immutable, read-only objects (such as string literals or objects created at compile/startup time that never change). The GC never scans, marks, or moves objects on the FOH during a collection pass. This eliminates GC overhead entirely for these objects and improves CPU cache efficiency. ### Anatomy of a Garbage Collection Pass When an allocation request exceeds available space in Gen 0 or memory pressure triggers a collection, the GC executes a multi-phase workflow: 1. Marking Phase: The GC constructs an object graph starting from “roots” (stack frames, CPU registers, static references, GC handles). It walks all reachable pointers and marks those objects as live. If the collector finds no reachable pointers, then the objects are marked as dead. 2. Plan Phase: The GC evaluates the level of heap fragmentation across generations to decide between compaction (moving objects together) or sweeping (reclaiming dead space in place). 3. Relocate Phase: If compacting, pointer references across the entire application are updated to point to the proposed new memory locations. Ok, so the GC has done a pass and marked, planned and relocated, great! But in step 2 we mentioned that the GC will make a decision about compaction or sweeping. Lets take a deeper look at those now. #### Compact Phase (Default for Small Object Heap) - How it works: Live objects are relocated so they sit contiguously in memory. Pointers/references are updated to reflect the new memory locations, and the remaining space is left as one large, contiguous free block. - Why: Eliminates memory fragmentation and allows future allocations to be done via a fast pointer-bump. #### Sweep Phase (Default for Large Object Heap / Generational Sweeping) - How it works: Live objects are left exactly where they are in memory. The GC scans the dead spaces and links them into a “free list” so that future allocations of similar size can reuse those gaps. - Why: Avoids the expensive performance cost of copying large memory blocks around. Here is a helpful image to summarise the above steps: The .NET Garbage collector cycle. Note: image generated by AI and the LOH deliberately ommited ### Memory Leaks — Patterns and Diagnostics As discussed above, the garbage collection mechanisms in .NET are exceptionally powerful. The traversal of object graphs during the sweep phase allows the collector to identify objects that are no longer needed easily, freeing up memory. So, if the collector is so smart, why do we still hear about memory leaks and watch applications crash after running out of memory? Unfortunately, even with the best GC mechanisms in the world, poor application logic can cause references to remain accidentally reachable. This prevents the GC from reclaiming that memory. As these uncollected objects accumulate, overall memory consumption continues to rise. #### Typical Pitfalls - Captive Dependencies in Middleware: Injecting scoped services (e.g., DbContext) into a middleware's constructor. Since middleware instances are instantiated as singletons, the scoped object is trapped for the lifetime of the process, promoting short-lived request objects straight to Generation 2. - Unsubscribed Events: Long-lived publishers hold implicit references to subscriber instances via delegate targets. - Static Caches Without Eviction: Static collections grow indefinitely over the lifecycle of the process. - Captured Variables in Lambda Expressions: Closures keeping parent instances alive longer than expected. - Excessive Object Pinning: Holding pointers pinned via GCHandleType.Pinned across collections, which blocks memory compaction. #### Diagnostic Tooling Luckily the .NET ecosystem provides us with many useful tools to help diagnose memory issues: - dotnet-gcdump: Lightweight tool for capturing heap snapshots in production without noticeable performance degradation. - dotnet-dump: Captures full process dumps for deep analysis of object graphs, memory retention paths, and unmanaged memory usage. - PerfView & JetBrains dotMemory: Advanced profile tools for investigating allocation flame graphs and tracing memory growth over time. ℹ️ Note: I will not go into too much depth here, as these are powerful tools that deserve their own dedicated article. However, I shall return to them in a future post, where I plan to explore detecting common memory leaks in .NET applications in greater detail. ### When GC Goes Wrong — The impact of Frequent GC Runs (GC Thrashing) So by now, you are probably thinking, “Wow, the GC is really cool. I am so glad we do not have to worry about cleaning up everything we allocate!” And you would be right. However, this blog post was not written to brag about how impressive the GC is; it is here to highlight what it is, how it works, and why you need to understand it so you can resolve issues when things go wrong. Yes, the GC operates automatically by running collection passes, but there is an inherent danger here. If you allocate too many small objects too frequently, the free memory in Gen 0 gets consumed quickly. This forces the collector to run too often, which can cause severe performance degradation such as: - Latency Spikes: Non-background collections require Stop-the-World (STW) execution pauses. A STW basically occurs when the .NET CLR needs to pause all threads to safely perform a complex memory managmenet task. Frequent collection passes freeze application execution threads, causing noticeable tail-latency spikes in web APIs. - CPU Starvation: Object graph traversal, pointer relocation, and memory compaction are CPU-intensive operations. High GC activity consumes CPU cycles that should belong to business logic, driving overall server CPU usage toward 100%. - Thread Pool Starvation: As execution threads stall waiting for GC memory allocations to complete, incoming request queues overflow. The thread pool exhausts available threads, leading to HTTP request timeouts and 503 errors under high load. - The Promotion Spiral: When Gen 0 runs continuously under high allocation rates, short-lived objects don’t have time to die before a collection pass. They get promoted to Gen 1 and Gen 2, forcing expensive full collections far more frequently. 🪧 Random note: If you are interested in some of the more complex elements of why STW events still are a thing in modern .NET compared to other languages like GO, then I would highly recommend reading the discussions on Github here and here as they both give great insight into the GC design and its future. ### Writing GC-Friendly Code To mitigate the issues highlighted in the previous section, we need to write code in a way that makes life easier for the GC. Reducing the allocation rate is the most effective way to lower GC pressure and eliminate CPU spikes. To achieve this, you can adopt better coding practices such as: #### 1. Renting Buffers with ArrayPool<T> Allocating large or short-lived arrays inside frequently executed methods causes heavy Generation 0 churn or forces allocations onto the Large Object Heap (LOH). Renting a buffer allows memory reuse across calls. using System.Buffers; // BAD: Allocates a new byte array on every execution pass public async Task ProcessDataStreamAsync(Stream stream) { byte[] buffer = new byte[8192]; // Allocated on heap every method call int bytesRead = await stream.ReadAsync(buffer); // Process buffer... } // GOOD: Rents a shared buffer from the pool and returns it when finished public async Task ProcessDataStreamOptimisedAsync(Stream stream) { // Rent a buffer of at least 8192 bytes byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); try { int bytesRead = await stream.ReadAsync(buffer); // Process buffer using the bytesRead slice... } finally { // Return the buffer so it can be reused by future callers ArrayPool<byte>.Shared.Return(buffer); } } #### 2. Zero-Allocation String Operations (ReadOnlySpan<char> and string.Create) Standard string operations like Substring or string interpolation allocate new string objects. Using ReadOnlySpan<char> slices text in place, while string.Create writes formatted strings directly into pre-allocated memory. // BAD: Allocates multiple intermediate strings on the heap public string FormatOrderSummary(string prefix, int orderId, string suffix) { // String interpolation creates multiple internal allocations return $"{prefix.Trim()}-{orderId:D6}-{suffix.Trim()}"; } // GOOD: Uses Span slicing and string.Create for zero intermediate allocations public string FormatOrderSummaryOptimised(string prefix, int orderId, string suffix) { ReadOnlySpan<char> prefixSpan = prefix.AsSpan().Trim(); ReadOnlySpan<char> suffixSpan = suffix.AsSpan().Trim(); int totalLength = prefixSpan.Length + 1 + 6 + 1 + suffixSpan.Length; // Fills the string buffer directly in place without intermediate allocations return string.Create(totalLength, (orderId, prefix, suffix), (span, state) => { int position = 0; state.prefix.AsSpan().Trim().CopyTo(span); position += state.prefix.AsSpan().Trim().Length; span[position++] = '-'; state.orderId.TryFormat(span.Slice(position, 6), out _, "D6"); position += 6; span[position++] = '-'; state.suffix.AsSpan().Trim().CopyTo(span[position..]); }); } #### 3. Avoiding Async State Machine Allocations with ValueTask Returning Task<T> from asynchronous methods always allocates a Task object on the heap unless the runtime can use a cached task. When a method completes synchronously most of the time, ValueTask<T> eliminates this allocation entirely. private readonly Dictionary<string, User> _cache = new(); // BAD: Always allocates a Task<User> on the heap, even when returning from cache public async Task<User> GetUserAsync(string userId) { if (_cache.TryGetValue(userId, out var user)) { return Task.FromResult(user); // Allocates a Task object on the heap } return await FetchUserFromDatabaseAsync(userId); } // GOOD: Zero allocations when completing synchronously from memory public async ValueTask<User> GetUserOptimisedAsync(string userId) { if (_cache.TryGetValue(userId, out var user)) { return user; // Wraps struct on stack, zero heap allocation } return await FetchUserFromDatabaseAsync(userId); } private async Task<User> FetchUserFromDatabaseAsync(string userId) { // Database access logic... await Task.Delay(10); return new User(); } #### 4. Struct Wrappers and ref readonly Parameters Passing large structs into methods by value copies the entire struct payload on the stack. Using the in or ref readonly keyword passes a read-only reference to the value type, avoiding unnecessary stack copying while preventing accidental modification. public readonly struct ProcessingMetrics { public readonly long BytesProcessed; public readonly long ErrorCount; public readonly TimeSpan Duration; public readonly Guid BatchId; } // BAD: Copies the full struct payload onto the stack frame on every call public void LogMetrics(ProcessingMetrics metrics) { Console.WriteLine($"Batch {metrics.BatchId} processed {metrics.BytesProcessed} bytes."); } // GOOD: Passes a read-only pointer reference to the existing struct on the stack public void LogMetricsOptimised(in ProcessingMetrics metrics) { Console.WriteLine($"Batch {metrics.BatchId} processed {metrics.BytesProcessed} bytes."); } #### 5. Enforcing Max Size and Entry Sizing when using IMemoryCache Without a global SizeLimit and explicit entry sizing, IMemoryCache grows without bound on the heap. Setting .SetSize() on each entry is essential for the cache eviction engine to work. using Microsoft.Extensions.Caching.Memory; // 1. Configure MemoryCache with a global capacity limit (e.g., max 1000 units) var cacheOptions = new MemoryCacheOptions { SizeLimit = 1000 // Arbitrary total capacity units }; var cache = new MemoryCache(cacheOptions); // BAD: Missing .SetSize() causes SizeLimit to be completely ignored! cache.Set("user_123", new UserData()); // GOOD: Entry specifies its relative size so compaction can evict items properly var entryOptions = new MemoryCacheEntryOptions() .SetSize(1) // Occupies 1 unit toward the 1000-unit SizeLimit .SetSlidingExpiration(TimeSpan.FromMinutes(5)); cache.Set("user_123", new UserData(), entryOptions); #### 6. Avoiding Excessive LINQ Chaining in Hot Paths LINQ method chains allocate enumerator state machines, delegate instances, and intermediate collections on every call. In high-frequency paths, plain loops or Span<T> eliminate heap allocations entirely. // BAD: Allocates multiple iterators, delegates, and intermediate lists on the heap public int SumActiveUserScoresLINQ(IEnumerable<User> users) { return users .Where(u => u.IsActive) // Allocates WhereEnumerableIterator + Func delegate .Select(u => u.Score) // Allocates SelectEnumerableIterator + Func delegate .ToList() // Allocates List<int> on the heap .Sum(); } // GOOD: Zero heap allocations when iterating over arrays or spans public int SumActiveUserScoresOptimised(ReadOnlySpan<User> users) { int total = 0; foreach (ref readonly var user in users) { if (user.IsActive) { total += user.Score; } } return total; } #### 7. Implementing IDisposable Correctly Standardizing resource cleanup ensures unmanaged handles are freed promptly and removes instances from the GC’s finalization queue via GC.SuppressFinalize. public class SafeResourceHolder : IDisposable { private IntPtr _unmanagedResource; // Unmanaged resource handle private SemaphoreSlim _lock; // Managed resource handle private bool _disposed; public SafeResourceHolder() { _unmanagedResource = /* allocate unmanaged handle */; _lock = new SemaphoreSlim(0, 3); } // StandardDispose pattern public void Dispose() { Dispose(disposing: true); // Tells GC that the finaliser does not need to run, saving a collection pass GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // Free managed resources here if any exist _lock.Dispose(true); } // Free unmanaged resources if (_unmanagedResource != IntPtr.Zero) { // Free unmanaged handle... _unmanagedResource = IntPtr.Zero; } _disposed = true; } // Finaliser: Only runs as a fallback if Dispose() was never called ~SafeResourceHolder() { Dispose(disposing: false); } } ### What’s New in .NET 10? .NET 10 brings critical refinements to memory management, optimizing GC behaviour automatically for containerized environments and modern architectures. #### DATAS Enabled by Default: Elastic Container Memory Dynamic Adaptation To Application Sizes (DATAS) was introduced as an opt-in feature in .NET 8, but .NET 10 makes it the default behavior for Server GC. - The Problem It Solves: Traditional Server GC scales heap size aggressively based on available system memory rather than active application demand. In Kubernetes clusters or containerized environments, this caused idle or low-traffic pods to hold onto massive heaps, triggering unnecessary cloud memory costs or Out-Of-Memory (OOM) kills by orchestrators. - How DATAS Works: DATAS dynamically monitors application throughput and active live-data size. During light traffic, it aggressively shrinks heap reservation and returns unneeded memory pages back to the operating system. During traffic bursts, it scales the heap allocation limit seamlessly — ensuring low memory footprints without manual GCHeapHardLimit tuning. - Deep dive: Preparing for the .NET 10 GC (DATAS) — .NET Blog #### JIT-Driven Stack Allocation & Advanced Escape Analysis In .NET 10, the JIT compiler and the GC work in tandem through improved Escape Analysis to eliminate heap allocations entirely before C# code even executes. - Non-Escaping Lambda Closures: When a lambda expression or delegate does not escape its enclosing method scope (for instance, passing a predicate to a local inline method), the JIT allocates the closure environment directly on the stack rather than on Generation 0. - Stack-Allocated Value-Type Arrays: Small fixed-size arrays of primitive or value types created within local method scopes are automatically transformed by the JIT into stackalloc structures when the compiler proves the reference cannot escape the call frame. - Deep dive: What’s new in .NET 10 runtime | Microsoft Learn public class EscapeAnalysisDemos { // 1. Stack-Allocated Value-Type Arrays // In .NET 10, the JIT proves 'data' does not escape local execution. // It stack-allocates the buffer automatically: zero Gen 0 allocation! public int ProcessLocalData() { int[] data = new int[] { 10, 20, 30, 40 }; // No heap allocation in .NET 10 int sum = 0; foreach (var val in data) sum += val; return sum; } // 2. Non-Escaping Lambda Closures // Capturing local variables inside a closure historically created a display class on the heap. // In .NET 10, because 'predicate' is consumed locally and does not escape, // the closure environment is allocated directly on the stack frame. public int FilterAndCalculate() { int threshold = 25; ReadOnlySpan<int> numbers = stackalloc int[] { 10, 20, 30, 40, 50 }; // The captured 'threshold' variable is placed in a stack-allocated closure scope return LocalSum(numbers, num => num > threshold); // Local helper accepting the delegate pass-through static int LocalSum(ReadOnlySpan<int> values, Func<int, bool> predicate) { int total = 0; foreach (var v in values) { if (predicate(v)) total += v; } return total; } } } ### Dynamic ARM64 Write-Barrier Selection (WriteBarrierManager) With cloud infrastructure shifting heavily toward ARM64 processors (such as AWS Graviton and Apple Silicon), .NET 10 optimizes hardware-level memory instruction paths via a new runtime subsystem: WriteBarrierManager. - Hardware-Specific JIT Patching: Rather than using generic assembly routines for write barriers, the runtime queries CPU cache line sizes and memory ordering rules at startup. - Targeted Assembly Variants: It dynamically patches the execution code with hyper-optimized ARM64 assembly variants tailored specifically to the host architecture, reducing CPU cache miss rates and lowering total GC scanning overhead across multi-threaded applications. - Deep dive: Smarter Write Barriers For Arm64 In .NET CoreCLR ### Final Thoughts Automatic memory management frees us to focus on solving business problems, but high-throughput systems require a developer who understands the rules of the road. From the basic mechanics of stack vs heap allocations to the zero-allocation JIT escape analysis in .NET 10, knowing how the GC behaves empowers you to write C# that works with the runtime, not against it. Profiling allocation counts today is the easiest way to prevent p99 latency spikes in production tomorrow. Thank you all for taking the time today to read my blog 🙏 ### Further reading - Fundamentals of garbage collection — .NET | Microsoft Learn - Workstation vs. server garbage collection (GC) — .NET | Microsoft Learn - Dynamic adaptation to application sizes (DATAS) — .NET | Microsoft Learn - Garbage Collection and Performance — .NET | Microsoft Learn - Preparing for the .NET 10 GC (DATAS) — .NET Blog - What’s new in .NET 11 | Microsoft Learn - The Garbage Collection Handbook
Maarten Balliauw {blog}
MediumMedium
TALENT ACQUISITION AND RECRUITING

The HTTP Logging Anti-Pattern Cluttering Your .NET Code: Ditch the try-catch Bloat

Ditch the try-catch bloat — Note: Image generated by AI ### The HTTP Logging Anti-Pattern Cluttering Your .NET Code: Ditch the try-catch Bloat If you have spent any time building or maintaining modern ASP.NET services, chances are you have run into situations where your codebase is littered with try...catch blocks whose sole purpose is to log request and response patterns before re-throwing. While there is nothing inherently wrong with this approach, it often leaves your core domain and business logic polluted with inconsistent diagnostic patterns. For instance: - Which exception types do you catch? HttpClient can throw around five different exception types depending on the failure scenario. - How do you parse the response body? Do you attempt to deserialise it into a standard payload structure, or simply read it as a string? - Should you log all failure states? Following standard RESTful conventions, status codes like 404 and 403 usually signal expected terminal states. They imply your call was successful, but the requested resource either does not exist or, in some cases (such as a user endpoint), you lack authorization to access it. - How do you handle sensitive information? Certain fields in your request or response payload (such as email addresses or dates of birth) constitute Personally Identifiable Information (PII). Logging this data indiscriminately creates a serious risk of leaking sensitive personal details. Fortunately, modern .NET offers elegant, infrastructure-level alternatives for both incoming server-side requests and outgoing client-side calls. Here is how to implement clean HTTP request and response logging without cluttering your application logic. ℹ️ Disclaimer: This article is the result of my own writing and creative process. It has not been generated using AI*. The only AI used was for image generation, and any AI-generated images are clearly credited as such.* ### ⚠️ A General Word Of Warning - Enabling excessive logging in your production environment will inevitably have some impact on your performance. - Ensure you understand the trade-offs before you enable a blanket logging of all information. - Turn on logging only for specific troubleshooting purposes and turn it back off when not needed. - Be mindful of logging classified data or PII data as this can lead to security related incidents. ### The Problem: The “Catch-and-Log” Anti-Pattern There are 2 common anti-pattern scenarios I see in modern ASPNET applications. - Custom middleware written from scratch to log incoming server payloads (often introducing subtle stream bugs). - Service methods littered with try-catch blocks whose sole purpose is to record outbound HTTP requests and responses before rethrowing. Lets take a look at each of these: #### Homemade Custom Server Middleware Before diving into HttpClient, consider what many teams write on the server side to log incoming HTTP requests and responses via custom middleware: // Homegrown middleware: Fragile, manual stream handling public class CustomHttpLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<CustomHttpLoggingMiddleware> _logger; public CustomHttpLoggingMiddleware(RequestDelegate next, ILogger<CustomHttpLoggingMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { // Must manually enable rewindable streams context.Request.EnableBuffering(); using var reader = new StreamReader(context.Request.Body, leaveOpen: true); var requestBody = await reader.ReadToEndAsync(); // Forget this and model binding breaks downstream! context.Request.Body.Position = 0; _logger.LogInformation("Incoming Request: {Body}", requestBody); // Swap the response stream to intercept outbound payload var originalResponseBody = context.Response.Body; using var responseStream = new MemoryStream(); context.Response.Body = responseStream; // invoke the next middleware chain delegate await _next(context); // now we are in the response cycle responseStream.Position = 0; var responseBody = await new StreamReader(responseStream).ReadToEndAsync(); _logger.LogInformation("Outgoing Response: {Body}", responseBody); responseStream.Position = 0; await responseStream.CopyToAsync(originalResponseBody); } } #### ❓ Why custom middleware falls short: - Stream Position Bugs: Forgetting Position = 0 or omitting EnableBuffering() silently breaks model binding or downstream middleware. - Performance Overhead: Allocating MemoryStream buffers for every request causes memory pressure under load. - Lack of Guardrails: Without body size limits or header redaction built in, large file uploads or bearer tokens can easily spill into your logging sinks. - Support Overhead: This code will need maintaining through multiple .NET SDK versions as well as potential extension to log more things in future. #### The outgoing HttpClient request logging scenario: On the client side (outgoing requests), developers often fall into a similar trap — wrapping every API call in a try-catch block simply to log diagnostic data: // Anti-pattern: Cluttering business logic with HTTP details // and unnecessarily materialising the response as a string. public async Task<UserDto?> GetUserAsync(int id) { try { var response = await _httpClient.GetAsync($"/users/{id}"); var content = await response.Content.ReadAsStringAsync(); // Anti-pattern: Logging the entire response body as a string _logger.LogInformation("Received response: {Content}", content); response.EnsureSuccessStatusCode(); // Anti-pattern: Deserialising from an intermediate string return JsonSerializer.Deserialize<UserDto>(content); } catch (HttpRequestException ex) { // Anti-pattern: Exception handling used purely for diagnostic side-effects _logger.LogError(ex, "HTTP call failed for user {Id}", id); throw; } } #### ❓ Why this is poor practice: - Pollutes Core Logic: Business operations become obscured by repetitive diagnostic code. - Poor Performance: The response body is read twice — first by ReadAsStringAsync and then again when the deserialisation method is invoked. - Inconsistent Diagnostics: Manual logging leads to mismatched log formats, forgotten endpoints, and accidental leaks of sensitive payload data. - Misaligned Responsibilities: Exceptions should manage exceptional control flow, not serve as hooks for standard HTTP telemetry. - Incomplete Error Handling: This approach fails to catch cancellation scenarios/timeouts (such as a TaskCanceledException). 💡 For more tips about how to handle HttpClient timeout scenarios effectively. Please take a look at my blog post Handling Timeouts in C# like a pro ### Best practice: Server-Side incoming request logging using Native ASP.NET Middleware Many engineers do not realise that Microsoft has already built a highly configurable HTTP logging middleware into ASP.NET Core. It handles request and response logging centrally before your controllers or endpoints ever execute. This middleware is included out of the box in the Microsoft.AspNetCore.App framework, so it is automatically available in any Web SDK project (<Project Sdk="Microsoft.NET.Sdk.Web">). While this middleware is comprehensively documented in Microsoft’s HTTP logging in .NET and ASP.NET Core guide, here is a quick overview of how to set it up: #### Configuring middleware When adding the middleware to your service collection, you can configure what should be added to your logs via simple action lambda: builder.Services.AddHttpLogging(logging => { // Specify exact fields to capture logging.LoggingFields = HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode | HttpLoggingFields.ResponseBody; logging.RequestHeaders.Add("Authorization"); logging.ResponseHeaders.Add("SomeResponseHeader"); // Select which encoding to use for a specific media type logging.MediaTypeOptions.AddText("application/json"); // Set buffer limits logging.RequestBodyLogLimit = 4096; logging.ResponseBodyLogLimit = 4096; // configures the middleware to consolidate all of its enabled logs // for a request and response into one log at the end logging.CombineLogs = true; }); // register with app builder var app = builder.Build(); // Place early in the pipeline to capture incoming requests cleanly app.UseHttpLogging(); ℹ️ Note: By default all request/response headers are redacted from the logs for security purposes. You can override this behaviour as shown above by adding to the RequestHeaders and ResponseHeaders collection to explicitly define the headers you want to appear in your logs unredacted. #### Configuring the log level As with all logging in the .NET ecosystem, log levels are fully configurable via your appsettings.json configuration. HttpLoggingMiddleware is no different and can be configured by setting an override for the Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware namespace to Information : { "Logging": { "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information" } } } #### Redacting sensitive information One of the biggest benefits of this middleware is that Microsoft has already taken into account the sensitive nature of some of your request/response data. They allow you to easily customise how the redaction works via configuration too: // Use your classifications during service registration builder.Services.AddRedaction(); builder.Services.AddHttpLoggingRedaction(op => { // Configure if path parameters should be redacted op.RequestPathParameterRedactionMode = HttpRouteParameterRedactionMode.None; // Determines how the request path is logged op.RequestPathLoggingMode = IncomingPathLoggingMode.Formatted; // Setup data classes using classifications for request/response headers // and route parameters op.RequestHeadersDataClasses.Add(HeaderNames.Accept, DataClassification.Public); op.ResponseHeadersDataClasses.Add(HeaderNames.ContentType, DataClassification.Private); op.RouteParameterDataClasses = new Dictionary<string, DataClassification> { { "param-one", DataClassification.Personal }, }; // Add the paths that should be filtered, with a leading '/'. op.ExcludePathStartsWith.Add("/home"); // Allow logging of unmatched path routes rather than Unknown op.IncludeUnmatchedRoutes = true; }); ℹ️ Note: This redaction functionality is built on top of the Microsoft.Extensions.Compliance.Redaction nuget package so if you are unfamiliar with this I would highly recommend reading up on MSDN or this awesome blog post by Andrew Lock ### Best Practice: Client Side HttpClient logging using Microsoft.Extensions.Http.Diagnostics For outbound HTTP requests made via IHttpClientFactory, Microsoft provides a dedicated, highly optimized package called Microsoft.Extensions.Http.Diagnostics. dotnet package add Microsoft.Extensions.Http.Diagnostics This package means that instead of manually writing custom delegating handlers, logging scopes or cluttering service calls with bloated try..catch statements we can simply delegate the responsibility off to .NET Extended Diagnostics and IHttpClientFactory. ℹ️ Note: This extension is comprehensively documented here: HTTP client logging in .NET but again I think its worth doing a quick overview here to help you understand the high level usage. #### Enabling logging for all HttpClients clients At this point I’m going to assume you are familiar with the concept of the HttpClientBuilder pattern in .NET and the concept of typed clients but if you are new to .NET I would highly recommend you read up on MSND best practices. So to setup HttpClient logging is super simple: HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); // Register redaction services (required) builder.Services.AddRedaction(); // Add enhanced HTTP client logging to all clients builder.Services.AddExtendedHttpClientLogging(); // Apply to a named client builder.Services.AddHttpClient("MyNamedClient") .AddExtendedHttpClientLogging(); // Apply to a typed client builder.Services.AddHttpClient<MyApiClient>() .AddExtendedHttpClientLogging(); // Add your HTTP clients builder.Services.AddHttpClient<MyApiClient>(); And that is it, you can add logging to all clients, a single typed client, or a named client. Simple right? ⚠️ Warning: You must register redaction services by calling AddRedaction from the Microsoft.Extensions.Compliance.Redaction package. The HTTP client logging feature redacts sensitive information by default (such as route parameters) and requires a redactor provider in the dependency injection container. #### Customising the logging configuration As with the logging middleware we discussed above, one of the core benefits if using these Microsoft packages is the extensive configuration options available to you; and the HttpClient logging is no different: builder.Services.AddExtendedHttpClientLogging(options => { // Log request headers with data classification options.RequestHeadersDataClasses.Add("User-Agent", DataClassification.None); options.RequestHeadersDataClasses.Add("Authorization", DataClassification.Unknown); // Log response headers options.ResponseHeadersDataClasses.Add("Content-Type", DataClassification.None); // Enable request/response body logging (use carefully in production) options.LogBody = true; // Limit request/response body reading to 4KB options.BodySizeLimit = 4096; // Ensure that application/json is considered a content type we want to log options.RequestBodyContentTypes.Add("application/json"); options.ResponseBodyContentTypes.Add("application/json"); }); ℹ️Note: I’ve gone through a few of the high level configuration options here. For more comprehensive details on configuration please consult the MSDN documentation here. Also remember that this can be configured per client or as a default for all clients! #### Configuring the log level The extended HttpClient logging functionality once again supports the standard appsettings.json log level overrides. However, this time you need to appreciate that it dynamically changes its log level depending on what happens with the request and response. The library logs at the following levels: - Information: When making a successful outgoing request, or when the response status code is outside the 400–599 range. - Error: When the outgoing request fails to complete due to a timeout, or when the response status code is within the 400–599 range. If you want to ensure that your Information-level logs are recorded correctly, you can add an override to your appsettings.json like so: { "Logging": { "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", "Microsoft.Extensions.Http.Logging": "Information" } } } 💡 Tip: Because Microsoft.Extensions.Http.Logging automatically uses the Error level for failed requests and responses, you do not need to worry about excessive logging impact unless your upstream service suffers a substantial outage. This approach provides the diagnostics you need during failure scenarios without requiring you to permanently enable the Information-level override. ### Conclusion: Elevating Your .NET Diagnostics Clean diagnostic logging should never come at the expense of readable domain logic or performance. By stepping away from the habit of wrapping everything in try...catch blocks and abandoning fragile, custom stream manipulation, you unlock several immediate wins across your codebase: - Maintainable Service Layers: Your business logic remains focused on domain execution, returning results or letting genuine exceptions bubble up naturally. - Production-Safe Streaming: Native middleware and LoadIntoBufferAsync guard against memory leaks, stream exhaustion, duplicate request/response reading and broken deserialisation pipelines. - Built-in Security: Centralised header redaction and body size limits ensure sensitive credentials and massive file uploads never land in your logging sinks. - Architectural Consistency: Inbound and outbound HTTP telemetry follow structured, predictable formats across every service in your solution. #### Next Steps - Audit your codebase: Search for service methods containing try...catch blocks that simply log and re-throw HttpRequestException or HttpResponseException, then strip them out where possible. Remember: every line of code you delete is a line of code you do not have to maintain. - Leverage Microsoft Diagnostics: Use AddExtendedHttpClientLogging() when registering your IHttpClientFactory instances via the Microsoft.Extensions.Http.Diagnostics NuGet package. - Standardise server traffic: Replace custom server HTTP logging middleware with ASP.NET Core’s native app.UseHttpLogging(). - Reserve custom solutions for edge cases: Only resort to custom request and response logging logic — whether client or server side — when you need highly niche, status-code-specific behaviour. By pushing diagnostic logging out to the infrastructure boundary where it belongs, your code stays lean, your team stays productive, and your log sinks remain clean and action-oriented. Thank you for taking the time to read through my blog today 🙏 ### Additional reading: - Microsoft.Extensions.Http.Diagnostics Github repo — go look here for the source code to help you understand how the configuration works etc. - HTTP client logging in .NET — .NET | Microsoft Learn - HTTP logging in ASP.NET Core | Microsoft Learn - Logging in .NET and ASP.NET Core | Microsoft Learn - Data redaction in .NET — .NET | Microsoft Learn - HttpClient guidelines for .NET — .NET | Microsoft Learn
MICROSOFT
Code4IT
MediumMedium
HUMAN RESOURCES AND WORKFORCE PLANNING
Async or Swim: The Complete Guide to Asynchronous .NET
Async or Swim: The Complete Guide to Asynchronous .NET 👋 Introduction Asynchronous execution is now ubiquitous within software engineering. Often, we find ourselves needing to make a request to an …
MICROSOFT
Sahan Serasinghe - Engineering NotesEnterprise CraftsmanshipCode4ITfsharpforfunandprofitMaarten Balliauw {blog}